AACsearch
SDKРецепты

B2B-каталог с ограниченным доступом

Ценовые уровни для каждого клиента, видимость SKU по клиентам и каталоги на основе контрактов — всё обеспечивается на серверной стороне через токены поиска с ограниченной областью действия.

B2B-витрина обслуживает разные цены, разные SKU и разную доступность для разных клиентов. Цена, которую видит оптовый клиент, не должна быть видна розничному. Этот рецепт обеспечивает это через токены поиска с ограниченной областью действия, создаваемые на серверной стороне для каждого авторизованного клиента.

Модель угроз

Если вы поместите логику ценовых уровней на клиент, клиент может открыть инструменты разработчика, изменить свой уровень с retail на vip и увидеть VIP-цены. Он не может сделать это с токеном с ограниченной областью действия: scopedFilter токена подписан на серверной стороне и комбинируется через И с фильтром вызывающего при каждом запросе. Клиент не может удалить или расширить его.

См. Токены поиска с ограниченной областью действия для криптографической модели.

Схема индекса

Закодируйте поля, специфичные для клиентского уровня, непосредственно в каждом документе:

type Product = {
  id: string;
  title: string;
  brand: string;
  categories: string[];
  visible_to_tiers: string[]; // ["retail", "wholesale", "vip"]
  price_retail: number;
  price_wholesale: number;
  price_vip: number;
  in_stock: boolean;
  organization_id: string; // организация вашего клиента, НЕ ваша
};

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

await admin.createIndex({
  slug: "products",
  fields: [
    { name: "title", type: "string" },
    { name: "brand", type: "string", facet: true },
    { name: "categories", type: "string[]", facet: true },
    { name: "visible_to_tiers", type: "string[]", facet: true },
    { name: "organization_id", type: "string", facet: true },
    { name: "in_stock", type: "bool", facet: true },
    { name: "price_retail", type: "float" },
    { name: "price_wholesale", type: "float" },
    { name: "price_vip", type: "float" },
  ],
  defaultSortingField: "price_retail",
});

Создание токена для авторизованного клиента

// app/api/search-token/route.ts — Next.js Route Handler
import { NextResponse } from "next/server";
import { getServerSession } from "@/lib/auth";
import { orpc } from "@/lib/orpc";

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

  const { customerOrgId, customerTier } = session.user;

  // Построение фильтра, сужающего каталог до этого клиента
  const scopedFilter = [
    `organization_id:=${customerOrgId}`,
    `visible_to_tiers:[${customerTier}]`,
    "in_stock:=true",
  ].join(" && ");

  const token = await orpc.search.createScopedToken.call({
    organizationId: process.env.AACSEARCH_ORG_ID!, // ваша организация, не клиента
    indexSlug: "products",
    scopedFilter,
    expiresInSeconds: 4 * 60 * 60, // 4 часа
    name: `b2b-${customerOrgId}-${customerTier}`,
  });

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

Сессия клиента определяет фильтр — он не может его изменить.

Использование токена в браузере

"use client";
import { useEffect, useState } from "react";
import { SearchClient } from "@aacsearch/client";

export function CatalogSearch() {
  const [client, setClient] = useState<SearchClient | null>(null);

  useEffect(() => {
    fetch("/api/search-token", { method: "POST" })
      .then((r) => r.json())
      .then(({ token }) => {
        setClient(
          new SearchClient({
            baseUrl: process.env.NEXT_PUBLIC_AACSEARCH_BASE_URL!,
            apiKey: token,
            indexSlug: "products",
          }),
        );
      });
  }, []);

  if (!client) return <SkeletonGrid />;
  return <ProductGrid client={client} />;
}

Браузер видит только товары, которые ему разрешено видеть. Проверка токена раскрывает фильтр (он в base64, не зашифрован), но подделка его инвалидирует HMAC-подпись → сервер отвечает 401 invalid_or_expired_scoped_token.

Ценообразование по уровням в UI

Документ содержит все три цены. UI выбирает правильную из сессии — сервер уже отфильтровал строки, которые клиент может видеть, так что чтение правильной колонки цены — это просто решение об отображении:

function priceFor(product: Product, tier: "retail" | "wholesale" | "vip") {
  switch (tier) {
    case "vip":
      return product.price_vip;
    case "wholesale":
      return product.price_wholesale;
    default:
      return product.price_retail;
  }
}

Если вы предпочитаете, чтобы документ не содержал цены, которые клиент не может использовать, храните по одной коллекции на уровень и пусть токен с ограничением выбирает правильный indexSlug. Это тяжелее по стоимости индексации, но надёжнее по утечке данных.

Обновление при смене сессии

Когда уровень клиента меняется (переход с оптового на VIP, продление контракта), инвалидируйте закешированный токен и перезапросите:

useEffect(() => {
  const onSessionChange = () => {
    fetch("/api/search-token", { method: "POST" })
      .then((r) => r.json())
      .then(({ token }) => client?.setApiKey(token));
  };
  window.addEventListener("session:changed", onSessionChange);
  return () => window.removeEventListener("session:changed", onSessionChange);
}, [client]);

Аудиторский след

Каждая выдача токена с ограниченной областью действия логируется с полем name — установите его в осмысленное значение (например, b2b-${customerOrgId}-${customerTier}), чтобы позже можно было провести аудит «кто какой каталог когда получил» из Поиск → Аудиторский журнал.

Комбинирование с фильтрами вызывающего

Клиент по-прежнему может добавлять собственные фильтры — они комбинируются через И поверх фильтра с ограничением:

// Фильтр вызывающего (в браузере)
client.search({
  q: "shoes",
  filterBy: "brand:=Nike",
});

// Эффективный фильтр, применяемый сервером:
//   organization_id:=org_xyz && visible_to_tiers:[vip]
//     && in_stock:=true && brand:=Nike

Фильтр с ограничением нельзя расширить, только сузить.

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

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

On this page