Перейти к содержанию

Архитектура и интеграции

Общая архитектура платформы

AACSearch — многоарендная SaaS-платформа, построенная на микросервисной архитектуре. Каждый компонент независимо масштабируется и отвечает за конкретную доменную область.

graph TD
    subgraph Clients["КЛИЕНТЫ"]
        direction LR
        Panel["Panel SPA<br/><i>Cookie-сессия</i>"]
        API["REST API<br/><i>Bearer JWT</i>"]
        M2M["Machine-to-Machine<br/><i>jwt-bearer grant</i>"]
    end

    subgraph CF["api.aacsearch.com — Cloudflare Workers Edge"]
        direction TB
        MW["<b>Middleware Pipeline</b><br/>1. authConfig() → Auth.js<br/>2. tenantResolver() → определение тенанта<br/>3. apiAuth() → JWT/Session/Key<br/>4. billingGate() → проверка подписки<br/>5. rateLimit() → лимиты<br/>6. featureGate() → доступность фич"]

        subgraph Handlers["Route Handlers"]
            direction LR
            H1["Search<br/>AI, Keys<br/>Reindex"]
            H2["Documents<br/>DataSrc"]
            H3["Billing<br/>Dashboard<br/>Webhooks"]
            H4["Typesense<br/>Engine Proxy"]
            H5["Auth V2"]
        end

        Storage["KV Cache &bull; D1 (data-sources) &bull; Queue (crawl/reindex)<br/>Secrets Store (9 секретов, персистентны между деплоями)"]

        MW --> Handlers
        Handlers --> Storage
    end

    subgraph TS["66.151.40.118"]
        Typesense["<b>Typesense</b><br/>Search Engine<br/>Port: 13000"]
    end

    subgraph Auth["185.130.225.35"]
        Zitadel["<b>Zitadel</b><br/>OIDC / OAuth<br/>Port: 8090"]
        subgraph LagoStack["Lago Stack"]
            Lago["<b>Lago</b><br/>Usage Billing<br/>Port: 3000"]
            DB["PostgreSQL &bull; Redis &bull; Sidekiq"]
        end
    end

    Panel & API & M2M --> CF
    H1 & H2 & H4 --> Typesense
    H5 --> Zitadel
    H3 --> Lago

Потоки данных

1. Поток аутентификации (Authorization Code)

sequenceDiagram
    participant U as Пользователь
    participant Z as Zitadel

    U->>Z: GET /oauth/v2/authorize<br/>scope=openid resourceowner
    Note over Z: Login V2 UI → ввод логин/пароль
    Note over Z: Action "inject-plan-code"<br/>org metadata → plan_code → JWT claim
    Z-->>U: Redirect → redirect_uri?code=auth_code
    U->>Z: POST /oauth/v2/token<br/>grant_type=authorization_code
    Z-->>U: access_token (JWT), id_token, refresh_token<br/>JWT: {sub, org_id, plan_code}

2. Поток аутентификации (Machine User / jwt-bearer)

sequenceDiagram
    participant M as Machine User
    participant Z as Zitadel
    participant A as API-шлюз

    Note over M: Генерация JWT:<br/>iss=client_id, sub=user_id, aud=issuer<br/>Подпись RSA private key

    M->>Z: POST /oauth/v2/token<br/>grant_type=jwt-bearer, assertion=signed_jwt<br/>scope=openid resourceowner
    Z-->>M: access_token (⚠ БЕЗ plan_code!)

    M->>A: API-запрос с Bearer token
    Note over A: plan_code из KV кеша<br/>(Actions НЕ выполняются для jwt-bearer)

3. Поток API-запроса (поиск)

graph TD
    REQ["POST /api/v1/search<br/>Bearer JWT, {q, limit}"]
    S1["[1] apiAuth() → JWKS верификация<br/>→ tenantId, planCode"]
    S2["[2] billingGate()<br/>KV → webhook маркер → Lago API"]
    S3["[3] rateLimit()<br/>KV: ratelimit:{tenantId}:{min}<br/>OK или 429"]
    S4["[4] Search Handler<br/>filter_by: tenant_id:={tenantId}<br/>→ Typesense"]
    S5["[5] waitUntil(trackSearchRequest())<br/>→ Lago event"]
    RES["Ответ: {total, hits}"]

    REQ --> S1 --> S2 --> S3 --> S4 --> S5 --> RES

    style REQ fill:#e3f2fd,color:#000
    style RES fill:#c8e6c9,color:#000

4. Поток Typesense Engine Proxy

graph TD
    REQ["POST /api/v1/engine/collections<br/>{name: 'products', fields}"]
    S1["[1] apiAuth() → tenantId"]
    S2["[2] billingGate() → подписка"]
    S3["[3] featureGate() → фича"]
    S4["[4] Engine Proxy<br/>name → t_{tenantId}__products<br/>→ Typesense"]
    S5["[5] Response rewrite<br/>Удаление префикса"]
    RES["Ответ: {name: 'products', ...}"]

    REQ --> S1 --> S2 --> S3 --> S4 --> S5 --> RES

    style REQ fill:#e3f2fd,color:#000
    style RES fill:#c8e6c9,color:#000

5. Поток биллинга (подписка)

sequenceDiagram
    participant L as Lago
    participant A as API-шлюз
    participant KV as KV Store
    participant Z as Zitadel

    L->>L: Создание подписки → webhook
    L->>A: POST /webhooks/lago<br/>X-Lago-Signature: HMAC
    Note over A: [1] Верификация HMAC
    A->>KV: [2] Дедупликация: wh:{uniqueKey}
    Note over A: [3] subscription.started
    A->>KV: PUT billing:{tenantId}:subscription_active (30д)
    A->>KV: DELETE entitlement + entitlements
    A-)Z: waitUntil: POST /metadata/plan_code<br/>x-zitadel-orgid: {orgId}

6. Поток синхронизации plan_code

graph LR
    L["Lago<br/>subscription.started"] -->|webhook| A["API-шлюз<br/>обработка вебхука"]
    A -->|"POST metadata"| Z["Zitadel<br/>org metadata: plan_code"]
    A --> KV["KV Cache<br/>subscription_active + plan_code"]
    Z --> ACT["complementToken()<br/>JWT claim: plan_code"]
    ACT --> JWT["Следующий JWT содержит<br/>plan_code для тенанта"]

    style L fill:#e3f2fd,color:#000
    style JWT fill:#c8e6c9,color:#000

Изоляция тенантов (Multi-Tenancy)

Уровни изоляции

Уровень Механизм Описание
Аутентификация JWT claim org_id Каждый тенант — отдельная организация в Zitadel
API Middleware apiAuth() tenantId извлекается из JWT, не из URL
Поиск filter_by: tenant_id:={tenantId} Принудительная фильтрация на уровне запроса
Коллекции Префикс t_{tenantId}__ Каждый тенант видит только свои коллекции
Биллинг external_id = tenantId Lago customer привязан к тенанту
Rate Limiting KV ключ ratelimit:{tenantId} Счётчики изолированы по тенантам
Кеширование KV ключ billing:{tenantId}:* Биллинговый кеш изолирован

Стратегия коллекций Typesense

graph LR
    subgraph A["Тенант A (abc123)"]
        A1["t_abc123__products"]
        A2["t_abc123__articles"]
        A3["t_abc123__users"]
    end
    subgraph B["Тенант B (xyz789)"]
        B1["t_xyz789__products"]
        B2["t_xyz789__documents"]
    end

Прокси-шлюз: - Запрос: клиент отправляет products → шлюз переписывает в t_abc123__products - Ответ: шлюз удаляет префикс → клиент получает products - Листинг: фильтрация по префиксу тенанта → клиент видит только свои коллекции


Кеширование

Стратегия KV Cache

Cloudflare KV используется как центральный кеш для минимизации обращений к внешним сервисам.

Категория Ключ TTL
Аутентификация zitadel:jwks 1 час
session:{sessionId} 5 мин
Биллинг billing:{t}:entitlement 5 мин (1 мин negative)
billing:{t}:subscription_active 30 дней
billing:{t}:plan_code 5 мин
entitlements:{t} 5 мин
Rate Limiting ratelimit:{t}:{min} 1 мин
Вебхуки wh:{uniqueKey} 7 дней
lago:webhook:public_key 1 день
Warnings billing:{t}:payment_failure 7 дней
billing:{t}:payment_overdue 7 дней
billing:{t}:wallet_depleted 1 час

Каскад проверок (Billing Gate)

graph LR
    A["1. KV entitlement"] -->|MISS| B["2. KV subscription_active"]
    B -->|MISS| C["3. Lago API (5с)"]
    A -->|HIT| R["Результат"]
    B -->|HIT| R
    C -->|OK| R
    C -->|offline| E502["502"]
    C -->|нет подписки| E402["402"]

    style R fill:#c8e6c9,color:#000
    style E502 fill:#ffcdd2,color:#000
    style E402 fill:#ffcdd2,color:#000

Инвалидация кеша

Кеш инвалидируется вебхуками Lago:

Событие Очищаемый кеш
subscription.started entitlement, entitlements
subscription.terminated Все billing:* и entitlements для тенанта
subscription.updated entitlement, entitlements, subscription_active

Учёт использования (Usage Tracking)

Биллинговые события

Событие Триггер Агрегация
search_request POST /api/v1/search COUNT
vector_search Typesense proxy + vector_query COUNT
rag_query Typesense proxy + conversations COUNT
documents_indexed POST /api/v1/documents SUM
documents_stored Cron (ежедневно в 2:00 UTC) MAX
geo_query Typesense proxy + geolocation COUNT
multi_search_query POST /multi_search COUNT
semantic_query Typesense proxy + semantic COUNT
image_query Typesense proxy + image COUNT
voice_query Typesense proxy + voice COUNT
join_query Typesense proxy + joins COUNT

Механизм отправки

Все события отправляются неблокирующе через executionCtx.waitUntil():

c.executionCtx.waitUntil(
  trackSearchRequest(env, tenantId, {
    query: request.q,
    results_count: response.total,
  })
);

Разрешение subscription_id

Каскад поиска ID подписки: 1. Параметр (если передан) 2. KV: billing:{tenantId}:subscription_activeexternal_id 3. Fallback: Lago API


Процесс деплоя

API (Cloudflare Worker)

Секреты хранятся в Cloudflare Secrets Store и персистентны между деплоями.

cd /root/api
npx wrangler deploy
# Готово! Пост-деплой шаги не требуются.

# Обновление секрета (при необходимости):
npx wrangler secrets-store secret update 26a0f16b21cb435c8c842e2a18787e56 \
  --name SECRET_NAME --remote

Panel (Cloudflare Pages)

cd /root/app/panel
npm run build
# Деплой dist/ на Cloudflare Pages (проект: aacsearch-platform)
# Домены: app.aacsearch.com, platform.aacsearch.com

Auth (Zitadel)

cd /root/auth
docker compose up -d

# Проверка здоровья:
docker compose ps
# zitadel должен быть healthy

Billing (Lago)

cd /root/billing
./dc.sh up -d

# Проверка:
./dc.sh ps
# lago-api, lago-worker, lago-clock должны быть running

# Миграции (при обновлении):
./dc.sh exec lago-api scripts/migrate.sh

Сетевая топология

Публичные домены

Домен IP/Сервис Порт TLS
api.aacsearch.com Cloudflare Workers Cloudflare Edge
docs.aacsearch.com Cloudflare Workers Cloudflare Edge
app.aacsearch.com Cloudflare Pages Cloudflare Edge
platform.aacsearch.com Cloudflare Pages Cloudflare Edge
auth.aacsearch.com 185.130.225.35 443 Nginx reverse proxy
billing-api.aacsearch.com 185.130.225.35 443 Nginx reverse proxy
billing.aacsearch.com 185.130.225.35 443 Nginx reverse proxy

Внутренние порты (localhost only)

Порт Сервис Сервер
8090 Zitadel Admin/API 185.130.225.35
3100 Zitadel User Info 185.130.225.35
3000 Lago API 185.130.225.35
8080 Lago Frontend 185.130.225.35
5432 PostgreSQL (Lago) 185.130.225.35
5433 PostgreSQL (Zitadel) 185.130.225.35
6379 Redis (Lago) 185.130.225.35
13000 Typesense 66.151.40.118

Сетевое взаимодействие

graph LR
    CF["Cloudflare Workers"]
    CF -->|"HTTPS :443"| Auth["auth.aacsearch.com<br/>JWT, JWKS, metadata"]
    CF -->|"HTTPS :443"| Billing["billing-api.aacsearch.com<br/>subscriptions, entitlements, events"]
    CF -->|"HTTP :13000"| TS["aacsearch.com<br/>search, collections, documents"]

Workers обращаются к бэкендам через публичные IP-адреса — туннели или VPN не требуются.


Критические замечания и «подводные камни»

Lago

  1. LAGO_API_URL должен заканчиваться на /api/v1 — SDK конкатенирует пути
  2. Lago SDK выбрасывает исключение на non-200 — нельзя деструктурировать {data, error}
  3. Timestamps — Unix epoch integer, не ISO 8601: Math.floor(date.getTime() / 1000)
  4. HMAC подпись — base64, не hex

Zitadel

  1. Actions v1 trigger binding — actionIds (массив), не actionId
  2. Actions complement_token НЕ выполняются для jwt-bearer grant (machine users)
  3. Org metadata API — POST /management/v1/metadata/{key} с x-zitadel-orgid
  4. getMetadata() возвращает {count, metadata: [{key, value}]}
  5. Scope urn:zitadel:iam:user:resourceowner необходим для получения tenantId

Cloudflare Workers

  1. Секреты в Secrets Store — персистентны, НЕ стираются при деплое. Обновление: wrangler secrets-store secret update
  2. c.header() в middleware работает только с c.json()/c.text(), для raw Response — c.res.headers.set() после await next()

Typesense

  1. API ключ — aac (3 символа, корректно)
  2. Geo-фильтр: field:(lat,lng,radius km/mi), НЕ geo()
  3. POST /collections — name должен быть с префиксом t_{tenantId}__

Rate Limiting

  1. Если planCode нет в контексте (маршрут не за billingGate), проверяются KV ключи billing:{tenantId}:entitlement и billing:{tenantId}:subscription_active