Архитектура и интеграции¶
Общая архитектура платформы¶
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 • D1 (data-sources) • 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 • Redis • 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_active → external_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¶
LAGO_API_URLдолжен заканчиваться на/api/v1— SDK конкатенирует пути- Lago SDK выбрасывает исключение на non-200 — нельзя деструктурировать
{data, error} - Timestamps — Unix epoch integer, не ISO 8601:
Math.floor(date.getTime() / 1000) - HMAC подпись — base64, не hex
Zitadel¶
- Actions v1 trigger binding —
actionIds(массив), неactionId - Actions
complement_tokenНЕ выполняются для jwt-bearer grant (machine users) - Org metadata API —
POST /management/v1/metadata/{key}сx-zitadel-orgid getMetadata()возвращает{count, metadata: [{key, value}]}- Scope
urn:zitadel:iam:user:resourceownerнеобходим для получения tenantId
Cloudflare Workers¶
- Секреты в Secrets Store — персистентны, НЕ стираются при деплое. Обновление:
wrangler secrets-store secret update c.header()в middleware работает только сc.json()/c.text(), для raw Response —c.res.headers.set()послеawait next()
Typesense¶
- API ключ —
aac(3 символа, корректно) - Geo-фильтр:
field:(lat,lng,radius km/mi), НЕgeo() - POST /collections —
nameдолжен быть с префиксомt_{tenantId}__
Rate Limiting¶
- Если
planCodeнет в контексте (маршрут не за billingGate), проверяются KV ключиbilling:{tenantId}:entitlementиbilling:{tenantId}:subscription_active