Документация системы

Сеть независимых сайтов + один агрегатор продаж мест и заявок. Тестовый стенд: PB на 127.0.0.1:8097 (публично mailfly.ru), статика на бакете cdn.mailfly.ru

1. Обзор и законы архитектуры 2. Путь заявки 3. Токен 4. Коллекции агрегатора и API rules 5. Файлы: что где лежит 6. Шифрование и ключи 7. Обход блокировок: функция-прокси 8. Кабинет клиента 9. S3, кэширование, версии 10. Чеклист: подключить новый сайт 11. Чеклист: перенос в прод 12. План: SPA-кабинет на S3

1. Обзор и законы архитектуры

Агрегатор (PocketBase) — продаёт места (sales), принимает заявки (leads), даёт кабинет. Не знает о товарах ничего: ни имён, ни цен, ни списков.
Сайт — чистая статика на S3, своей серверной части нет. Его «БД в рантайме» — два файла, выгруженные его CMS в бакет: витрина и owners-карта (§5).
Законы (нарушение любого = ошибка проектирования):
Прайс = список пакетов: [{count, perMonth:{"1":₽,"3":₽,…}}]. Покупка = выбор пакета: sales.capacity = count, amount = perMonth[срок] × срок. У «эксклюзива» в списке просто один пакет count:1.
capacity — что продано, лимит рекомендательный: соблюдает CMS сайта при выгрузке, при заявке не проверяется.

2. Путь заявки

1
Браузер на сайте: клик по кнопке → модалка (nav-lead.js) → POST https://mailfly.ru/api/leads/create с телом {token, data}. Отправка — напрямую, при блокировке — через функцию (§7).
data — произвольные поля формы, агрегатор их не разбирает. Туда же кладётся data.item — «Название — Регион» из витрины, чисто для чтения человеком.
2
Сервер (route_leads.pb.js): первые 15 символов токена → оффер → сайт → адрес его owners-файла (sites.ownersUrl).
3
Качает owners-файл с S3 (кэш в $app.store 60 сек), расшифровывает серверным ключом (§6), берёт owners[токен] → id владельца.
Владелец определяется в момент заявки. Смена владельца = перевыгрузка одного файла CMS-кой сайта.
4
Проверка в sales: активная покупка владельца (оффер+регион, cancelled пусто, purchased + term×30дн не истёк). Нет — 404 no_active_owner.
5
Создаётся lead {sale, data, status:'new'}. Ответ {ok:true, id}.
Пример запроса:
curl -X POST https://mailfly.ru/api/leads/create \
  -H 'Content-Type: application/json' \
  -d '{"token":"<offer15><region15><productId>",
       "data":{"name":"Иван","phone":"+7…","comment":"…"}}'
Ошибки: bad token (короче 31), unknown offer, site db unavailable (S3 не отдал файл), unknown position (нет в owners), no_active_owner.

3. Токен

offer id (15)region id (15)id товара в БД сайта (любая длина)
Одна строка без разделителей, сервер режет по позициям. Собирается в nav-site.js при рендере кнопки: openLead(p.offer + region.id + productId, title).
Ничего клиентского: токен не меняется при смене владельца. Целиком является ключом в owners-файле.

4. Коллекции агрегатора и API rules

users (auth) — клиенты и партнёры: name, phone, role(client/partner), rate, discount, messengers(json)
sites — name, url, ownersUrl (где лежит owners-файл). Rules: только суперюзер — клиент не должен видеть сеть (§8)
offers — site, name. Rules: клиент видит только офферы из собственных покупок: @collection.sales.offer ?= id && @collection.sales.owner ?= @request.auth.id
regions — name. Rules: аналогично offers (только из своих покупок)
prices — offer, region (пусто = все регионы), policy = список пакетов (§1). Rules: закрыто
sales — offer, region, owner, partner, partnerRate, term(мес), amount, purchased, cancelled, capacity. Rules: owner = @request.auth.id (list/view)
leads — sale(→sales), status(new/in_progress/done), data(json как есть). Rules: list/view sale.owner = @request.auth.id; update — тот же owner и @request.body.sale:changed = false && @request.body.data:changed = false (менять можно только status). Create — только серверный роут (минуя rules)

5. Файлы: что где лежит

Сервер — /opt/claude/trash/mailfly/pb_hooks/

Исходники фронта и генератора — /opt/claude/trash/mailfly/frontend/

Бакет cdn.mailfly.ru (S3)

Те же html/js + данные:
Клиентский SDK/доступ к PB у сайтов отсутствует полностью — их единственный «API» это эти два файла и POST /api/leads/create.

Функция

Yandex Cloud Functions, публичный вызов: https://functions.yandexcloud.net/d4ecerg825inqkehj1hg. Исходник — frontend/transfer-function/. Кредов не содержит: ключ шифрования конверта захардкожен симметрично в transfer.js и функции (§6).

6. Шифрование и ключи

Формат везде один: AES-256-GCM, base64(iv 12 байт ‖ шифртекст ‖ tag 16 байт). Совместим одновременно с PB $security.decrypt(строка, ключ32), Node crypto.createCipheriv('aes-256-gcm') и браузерным WebCrypto.
Три независимых ключа (значения в коде, тут не публикуются):
В прод — сгенерировать новые все три и вынести из кода (§11).

7. Обход блокировок: функция-прокси

Зачем: мобильные операторы режут по белым спискам — прямой запрос к домену агрегатора может не пройти, а functions.yandexcloud.net в списках есть.
Как шлёт клиент (leadsSubmit в nav-lead.js):
  1. прямой fetch на target с таймаутом 2500 мс
  2. не прошло → transferCall(FUNC_URL, key, {target, method, path, contentType, body}): конверт шифруется TRANSFER_KEY, функция расшифровывает, делает запрос к target от себя, ответ возвращает тем же шифрованным конвертом
Функция — тупой прокси, агрегатор отличий не видит. Заголовок Authorization функция пока НЕ пробрасывает — для SPA-кабинета потребуется доработка (§12).

8. Кабинет

GET /cabinet — сервер отдаёт статическую оболочку; все данные страница берёт из штатного PB API под токеном (auth-with-password, токен в localStorage cab_auth).
Вход один на всех: форма пробует авторизацию сначала как клиент (users), потом как админ (_superusers). Клиент видит только своё — режут API rules (§4); админа rules не ограничивают — те же экраны показывают всё + имя владельца на карточках. Отдельного экрана заявок нет — всё здесь.
Показывает: места (sales: оффер · регион, пакет N позиций, срок, сумма, активна до/истекла/отменена) и заявки (leads: данные формы + смена статуса новая → в работе → завершена).
Легенда «один сайт»: клиент не должен знать про сеть. В UI ни слова про сеть/сайты; API rules (§4) не дают перечислить чужое: sites 403, offers/regions — только из своих покупок.

9. S3, кэширование, версии

10. Чеклист: подключить новый сайт

  1. В агрегаторе: запись в sites (name, url, ownersUrl → куда сайт будет класть owners-файл), офферы в offers, прайсы-пакеты в prices. Регионы общие
  2. В CMS сайта: у товара поле «владелец» = id клиента из агрегатора (указывается при размещении). Товар заводится ОДИН раз, регионы размещения — ссылками
  3. Экспорт из CMS двух файлов (формат — §5, шифрование — §6): витрина + owners-карта {offerId+regionId+productId: ownerId}. Выгрузка в бакет при каждом изменении данных
  4. На страницах сайта: подключить transfer.js + nav-lead.js (или свой аналог формы), кнопка вызывает openLead(offer+region+productId, 'подпись'). Если витрина не шифруется — catalog-decrypt.js не нужен
  5. Продажа мест: запись в sales (пакет из прайса, capacity=count). Всё — заявки маршрутизируются
Ничего специфичного «под тип» сайта не настраивается — уникум это каталог из одного товара с пакетом count:1.

11. Чеклист: перенос в прод

  1. Сгенерировать новые CATALOG/OWNERS/TRANSFER ключи, вынести из кода (env или PB settings), старые считать скомпрометированными
  2. Свой домен агрегатора + бакеты сайтов; HTTPS к бакетам напрямую (§9)
  3. Задеплоить функцию в своё облако, обновить URL в nav-lead.js
  4. /api/leads/create: rate-limit + honeypot-поле против спама (сейчас нет)
  5. Уведомления владельцу о новой заявке (email/telegram; хук ext_ на onRecordAfterCreateSuccess leads) — не реализовано
  6. Смена паролей клиентов: сейчас тестовые (кабинет), включить сброс пароля по email
  7. Партнёрка: поля partner/partnerRate в sales есть, начисления не реализованы
  8. Мониторинг ответов no_active_owner / site db unavailable — сигнал рассинхрона выгрузки или падения S3
  9. Продление/оплата sales — вручную или биллинг, не реализовано

12. План: SPA-кабинет на S3

Кабинет уже фактически SPA — SSR-роут отдаёт только оболочку. Перенос:
  1. Сохранить HTML кабинета файлом в бакет (cabinet.html)
  2. Напрямую заработает сразу: PB отдаёт API с Access-Control-Allow-Origin: *
  3. Для обхода блокировок — обернуть вызовы API в паттерн leadsSubmit (§7) и доработать функцию+transfer.js: проброс заголовка Authorization
  4. SSE/realtime через функцию не работает — кабинет его не использует
  5. Удалить SSR-роут /cabinet