Files
VA.ASPEKTER/docs/02-developer-guide.md
Sergey Zotov c090bfcf47 Initial commit — Aspekter VA email builder
Full project: Svelte 5 frontend, Vite 7 backend API,
Pug email templates (email-gen), Docker deployment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 01:21:00 +05:00

575 lines
28 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Aspekter VA — Руководство разработчика
## 1. Обзор архитектуры
Aspekter VA — монолитное web-приложение для сборки email-рассылок VipAvenue.
```
┌─────────────────────────────────────────────────┐
│ Браузер │
│ App.svelte (UI) ←→ api.js (HTTP client) │
└────────────────────┬────────────────────────────┘
│ HTTP
┌────────────────────▼────────────────────────────┐
│ builder (порт 6001) │
│ Vite dev server + API middleware │
│ vite.config.js — весь backend (~1500 строк) │
└────────────────────┬────────────────────────────┘
│ HTTP (внутренний)
┌────────────────────▼────────────────────────────┐
│ email-gen-api (порт 8787) │
│ server.js — Pug → HTML рендер │
│ email-gen/ — шаблоны, стили, миксины │
└─────────────────────────────────────────────────┘
```
### Стек
- **Frontend:** Svelte 5 + Vite 7
- **Backend:** Vite middleware plugin (Node.js, встроен в dev-server)
- **Email рендер:** Pug шаблоны + email-templates (npm)
- **Хранение данных:** файловая система (JSON)
- **Инфраструктура:** Docker Compose, nginx reverse proxy
### Почему так устроено
Backend встроен как Vite middleware plugin, а не как отдельный сервер. Это значит:
- Один процесс, один порт
- HMR для фронтенда работает из коробки
- Нет CORS-проблем
- Но: vite.config.js не перезагружается через HMR — нужен `docker restart builder`
---
## 2. Структура файлов
```
VA.ASPEKTER/
├── docker-compose.yml # Оркестрация контейнеров
├── deploy/
│ ├── nginx/ # Nginx конфиг + лендинг
│ │ ├── nginx.conf
│ │ └── landing.html
│ └── email-gen-api/
│ ├── Dockerfile
│ └── server.js # Standalone Pug render service
├── email-gen/ # Git-репозиторий коллег (шаблоны)
│ └── emails/vipavenue/
│ ├── layout/layout.pug # Базовый layout (width=600)
│ ├── blocks/block.pug # Все блоки конструктора
│ ├── includes/mixins.pug # Pug миксины (товары, кнопки, preheader)
│ ├── css/style.css # Стили (инлайнятся при рендере)
│ └── parts/ # Хедеры, футеры (по гендеру)
│ ├── header/
│ └── footer/
└── z51-pug-builder/ # Основное приложение
├── Dockerfile
├── package.json
├── vite.config.js # ВЕСЬ BACKEND API (~1500 строк)
├── index.html # Входная точка Vite
├── public/ # Статика (favicon)
├── src/
│ ├── App.svelte # ВЕСЬ FRONTEND UI (~6400 строк)
│ ├── app.css # Все стили
│ ├── main.js # Точка входа Svelte
│ └── lib/
│ ├── api.js # API-клиент (все эндпоинты)
│ ├── parsing.js # Парсинг Pug-блоков, миксинов, аргументов
│ ├── spellcheck.js # Проверка орфографии (Yandex Speller)
│ └── utils.js # Утилиты (normalizeNewlines, escapeRegExp, etc)
└── data/ # Данные (том Docker)
├── config.json # Глобальный конфиг (Yonote токен, URL)
├── feed-cache.json # Кэш товарного фида
├── render-cache.json # Кэш рендера (Pug→HTML)
├── _system/
│ ├── users.json # Пользователи (логин, хэш пароля, роль)
│ └── sessions.json # Активные сессии
└── vipavenue/ # Данные проекта
├── settings.json # Настройки (блоки, FTP, гендер, фид)
├── block.pug # Текущий пуг-файл конструктора
├── presets.json # Пресеты
├── notes.json # Заметки (индекс)
├── notes/ # Файлы заметок
├── stats.json # Статистика времени
├── drafts/ # Черновики по пользователям
└── letters/ # Карточки писем по пользователям
└── {userId}/
├── letters.json # Индекс писем
├── {id}.json # Данные письма
└── {id}.history.json # История снимков
```
---
## 3. Docker — контейнеры и тома
### Контейнеры
| Контейнер | Образ | Порт | Назначение |
|---|---|---|---|
| `builder` | z51-pug-builder/Dockerfile | 6001→5173 | Vite dev server + API |
| `email-gen-api` | deploy/email-gen-api/Dockerfile | (внутренний) 8787 | Pug→HTML рендер |
| nginx | (отдельно) | 80/443 | Reverse proxy |
### Тома builder
| Хост | Контейнер | Режим | HMR? |
|---|---|---|---|
| `./z51-pug-builder/src` | `/app/src` | rw | ✅ Мгновенно |
| `./z51-pug-builder/vite.config.js` | `/app/vite.config.js` | rw | ❌ Нужен restart |
| `./z51-pug-builder/data` | `/app/data` | rw | — |
| `./email-gen` | `/email-gen` | rw | ❌ Нужен build |
### Тома email-gen-api
| Хост | Контейнер |
|---|---|
| `./email-gen` | `/workspace/email-gen` (rw) |
| Named volume | `/workspace/email-gen/node_modules` |
### Правила пересборки (КРИТИЧЕСКИ ВАЖНО)
**`docker compose build && docker compose up -d` нужен при:**
- Изменения в `email-gen/` (pug шаблоны, миксины, стили)
- Изменения в `deploy/email-gen-api/server.js`
- Изменения в `z51-pug-builder/Dockerfile`
- Изменения в `z51-pug-builder/package.json` (новые зависимости)
**`docker compose restart builder` нужен при:**
- Изменения в `z51-pug-builder/vite.config.js`
**Ничего не нужно (HMR подхватит):**
- Изменения в `z51-pug-builder/src/` (App.svelte, app.css, lib/*.js)
> **НЕ использовать** `docker cp` или `docker exec` для правки исходников на проде. Только редактировать на хосте, тома примонтированы.
---
## 4. Backend API — все эндпоинты
Весь API реализован в `vite.config.js` как Vite middleware plugin.
### Авторизация (без аутентификации)
| Метод | URL | Описание |
|---|---|---|
| POST | `/api/auth/login` | Вход: `{login, password}` → cookie `va_token` |
| POST | `/api/auth/logout` | Выход: удаляет cookie |
| GET | `/api/auth/me` | Текущий пользователь (или 401) |
| PUT | `/api/auth/preferences` | Сохранить тему/activePage/zoom |
### Администрирование (role: admin)
| Метод | URL | Описание |
|---|---|---|
| GET | `/api/admin/users` | Список пользователей |
| POST | `/api/admin/users` | Создать: `{login, password, name, role}` |
| PUT | `/api/admin/users/:id` | Обновить пользователя |
| DELETE | `/api/admin/users/:id` | Удалить (нельзя удалить себя) |
### Проект (все требуют аутентификации)
| Метод | URL | Описание |
|---|---|---|
| GET | `/api/projects` | Список проектов (всегда `["vipavenue"]`) |
| GET | `/api/project/:name` | Данные проекта (блок, настройки, черновик, пресеты, письма, заметки) |
| PUT | `/api/project/:name/block` | Сохранить block.pug |
| PUT | `/api/project/:name/block-custom` | Сохранить кастомный блок |
| PUT | `/api/project/:name/settings` | Сохранить настройки |
| PUT | `/api/project/:name/draft` | Сохранить черновик |
| PUT | `/api/project/:name/presets` | Сохранить пресеты |
### Письма
| Метод | URL | Описание |
|---|---|---|
| GET | `/api/project/:name/letter/:id` | Получить письмо |
| PUT | `/api/project/:name/letter` | Сохранить: `{id, ...data}` |
| DELETE | `/api/project/:name/letter/:id` | Удалить |
| GET | `/api/project/:name/letter/:id/history` | История снимков |
| PUT | `/api/project/:name/letter/:id/history` | Добавить снимок |
### Заметки
| Метод | URL | Описание |
|---|---|---|
| GET | `/api/project/:name/notes` | Индекс заметок |
| PUT | `/api/project/:name/notes` | Сохранить индекс |
| GET | `/api/project/:name/note/:id` | Получить заметку |
| PUT | `/api/project/:name/note` | Сохранить: `{id, ...data}` |
| DELETE | `/api/project/:name/note/:id` | Удалить |
### Рендер email
| Метод | URL | Описание |
|---|---|---|
| POST | `/api/project/:name/render-email` | Рендер Pug→HTML. Body: `{projectSlug, pug, preheader, gender}` |
**Цепочка рендера:**
1. Frontend отправляет Pug-код + preheader + gender
2. Backend формирует hash, проверяет кэш
3. Если кэш-промах → отправляет в email-gen-api
4. email-gen-api: записывает `letters/let.pug`, генерирует `html.pug`, запускает `email-templates``public/index.html`
5. Backend читает HTML, обрабатывает Mindbox-теги (подставляет товары), применяет nowrap
6. Возвращает `{html, previewHtml, unavailableProducts}`
### Товарный фид
| Метод | URL | Описание |
|---|---|---|
| POST | `/api/project/:name/feed-refresh` | Очистить кэш фида, перезагрузить |
| POST | `/api/project/:name/feed-lookup` | Найти товары по ID: `{ids: [...]}` |
| POST | `/api/project/:name/feed-suggest` | Подобрать замену: `{productId, excludeIds, search}` |
Фид кэшируется на 3 часа. XML парсится regex-ом (не DOM), поддерживается windows-1251.
### FTP/SFTP
| Метод | URL | Описание |
|---|---|---|
| POST | `/api/project/:name/ftp/test` | Проверить подключение |
| POST | `/api/project/:name/ftp/upload` | Загрузить файл: `{imageData, fileName, folder}` |
| POST | `/api/project/:name/ftp/list` | Список файлов: `{folder}` |
| POST | `/api/project/:name/ftp/delete` | Удалить файл: `{folder, fileName}` |
### Прочее
| Метод | URL | Описание |
|---|---|---|
| GET | `/api/config` | Глобальный конфиг (Yonote, upload URL) |
| PUT | `/api/config` | Сохранить конфиг |
| GET | `/api/yonote/status` | Статус подключения Yonote |
| GET | `/api/yonote/databases` | Список баз Yonote |
| GET | `/api/yonote/database/:id/properties` | Свойства базы |
| GET | `/api/yonote/database/:id/rows` | Строки базы |
| POST | `/api/yonote/row/update` | Обновить строку: `{rowId, values}` |
| POST | `/api/upload-image` | Загрузить изображение: `{imageData, fileName}` |
| POST | `/api/check-links` | Проверить ссылки: `{urls: [...]}` |
| GET | `/api/parts-files` | Список pug-файлов из parts/ |
| GET | `/api/parts-file-read?path=...` | Прочитать parts-файл |
| POST | `/api/parts-file-write` | Записать parts-файл |
| GET | `/api/project/:name/stats` | Статистика проекта |
| POST | `/api/project/:name/stats` | Сохранить запись статистики |
| GET | `/api/stats` | Вся статистика (все проекты) |
---
## 5. Frontend — архитектура
### Общая структура
Весь UI — один файл `App.svelte` (~6400 строк). Это монолит. Причина — проект начинался как прототип и вырос.
### Ключевые модули (lib/)
**api.js** — HTTP-клиент. Все функции `api*()` вызывают `apiRequest()` который делает `fetch()` с `Content-Type: application/json` и обрабатывает ошибки.
**parsing.js** — парсинг Pug-кода:
- `parseBlocks(text)` — разбивает Pug на блоки по `//` комментариям
- `buildBaseSchema(content, blockName, mixinRules)` — строит схему полей блока (какие поля редактируемые)
- `parseSections(content)` — разбивает блок на секции для drag-n-drop
- `findMixinArgRange(line, argIndex)` — находит позицию аргумента миксина в строке
- `extractMixinArgValue()` / `replaceMixinArgQuoted()` — чтение/запись аргументов
**spellcheck.js**`checkSpelling(text)` отправляет в Yandex Speller, `injectSpellMarks(html, errors)` подсвечивает ошибки в превью.
**utils.js**`normalizeNewlines()`, `escapeRegExp()`, `unquoteValue()`, `downloadFile()`.
### State management
Svelte 5 reactive `let` переменные. Ключевые группы:
| Группа | Переменные | Описание |
|---|---|---|
| UI | `activePage`, `theme`, `sidebarTab`, `settingsBlockIndex` | Состояние интерфейса |
| Данные | `assembledBlocks`, `presets`, `planRows`, `allStats` | Основные данные |
| Рендер | `renderedHtml`, `previewHtml`, `autoRenderTimer` | Результат рендера |
| Гендер | `currentGenderVersion`, `segmentFlipped` | Гендерная сегментация |
| Фид | `feedProducts`, `unavailableProducts` | Товары |
| Письма | `currentLetterId`, `letterName` | Текущее письмо |
| Настройки | `settings` | settings.json проекта |
### Ключевые функции
| Функция | Назначение |
|---|---|
| `loadProject()` | Загрузка проекта при старте |
| `rebuildOutput()` | Пересборка Pug из блоков → рендер |
| `renderEmail()` | Отправка Pug на сервер для рендера |
| `assembleGenderVersion(target)` | Сборка гендерной версии |
| `flipSegmentOrder()` | Переключение порядка блоков (⇅) |
| `addBlock(name)` / `removeBlock(id)` | Управление блоками |
| `updateBlockContent(id, content)` | Обновление содержимого блока |
| `saveDraft()` | Автосохранение черновика |
| `copyHtml()` | Копирование HTML в буфер |
| `handlePreviewElementClick(data)` | Клик по элементу в превью → фокус на поле |
### Реактивные вычисления ($:)
```javascript
$: settingsBlockName = allBlocks[settingsBlockIndex]?.name
$: settingsTemplate = getEffectiveTemplate(settingsBlockName)
$: baseSchema = buildBaseSchema(settingsTemplate, settingsBlockName, ...)
$: allBlocks = [...blockTemplates, ...customBlocksList]
$: productOptionsList = (settings.productOptions || []).map(...)
```
---
## 6. Email rendering pipeline
### Полная цепочка
```
1. App.svelte: assembleGenderVersion() + rebuildOutput()
↓ Собирает Pug из блоков
2. App.svelte: apiRenderEmail(name, {projectSlug, pug, preheader, gender})
↓ HTTP POST
3. vite.config.js: /api/project/:name/render-email
↓ pugHash() → проверка кэша
↓ Если промах:
4. vite.config.js → HTTP POST email-gen-api:8787/render
↓ {projectSlug, pug, preheader, gender, genderPaths}
5. email-gen-api/server.js:
a) Записывает letters/let.pug (содержимое из конструктора)
b) rewriteHtmlPug() — генерирует html.pug с правильным header/footer
c) renderWithNode() — вызывает email-templates (Pug → HTML)
d) Читает public/index.html → возвращает HTML
6. vite.config.js:
a) Кэширует HTML
b) processMindboxTags() — подставляет товары из фида
c) applyNowrap() — склеивает предлоги с nowrap-спанами
d) Возвращает {html, previewHtml, unavailableProducts}
7. App.svelte: показывает превью в iframe
```
### Кэш рендера
- Ключ: `md5(slug + pug + gender + genderPaths)`
- Максимум 30 записей (LRU)
- Персистится в `data/render-cache.json`
- Инвалидируется при смене Pug-кода, гендера или путей header/footer
### Mindbox-обработка
- Замена `@{for...}@{end for}` блоков
- Извлечение ID товаров из `GetByValue('12345')`
- Подстановка данных из фида (цена, название, картинка, URL)
- Отслеживание недоступных товаров
### Nowrap-обработка
- Только для текстовых блоков (span с классом `h3`)
- Оборачивает `предлог + слово` в `<span style="white-space:nowrap">`
- Использует placeholder-подход чтобы избежать двойной обработки
---
## 7. Гендерная сегментация — техническая реализация
### Данные
```json
// settings.json → genderPaths
{
"headerFemale": "./parts/header/header-woman",
"headerMale": "./parts/header/header-man",
"footerFemale": "./parts/footer/footer-woman",
"footerMale": "./parts/footer/footer-man"
}
```
### Цепочка передачи gender
1. **App.svelte**`apiRenderEmail(..., { gender: 'female'|'male' })`
2. **vite.config.js** → читает `genderPaths` из settings.json, форвардит в email-gen-api
3. **email-gen-api/server.js**`rewriteHtmlPug(projectDir, preheader, gender, genderPaths)` — записывает `html.pug` с нужным include header/footer
4. Pug рендерит HTML с правильным header/footer
### Кэш
`pugHash(slug, pug, gender, genderPaths)` — gender и genderPaths включены в ключ, female/male кэшируются отдельно.
### Flip (⇅)
- `flipSegmentOrder()` в App.svelte
- Находит блок-разделитель с `swapCenter = true`
- Блоки до разделителя и после меняются местами
- `segmentFlipped` (bool) отслеживает состояние toggle
---
## 8. Авторизация и безопасность
### Аутентификация
- **Хэширование:** scrypt (salt 16 bytes, key length 64)
- **Сессии:** случайный токен 32 bytes hex, хранится в Map + файл sessions.json
- **Cookie:** `va_token`, HttpOnly, SameSite=Strict, Secure, Max-Age=7d
- **Brute-force:** 5 попыток на IP за 15 минут
### Middleware стек (порядок важен!)
1. Security headers (X-Content-Type-Options, X-Frame-Options, Referrer-Policy)
2. Auth endpoints (/api/auth/*) — без проверки сессии
3. CSRF проверка (origin/referer vs host)
4. Auth middleware — проверка cookie → req.user
5. Admin middleware (/api/admin/*) — проверка role=admin
6. Uploads middleware (/uploads/*) — статика с проверкой path traversal
7. API middleware — основные эндпоинты
### Защита от атак
- **Path traversal:** `sanitizeFileId()` для всех файловых ID, `getProjectDir()` проверяет startsWith
- **SSRF:** фильтрация приватных IP (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16)
- **CSRF:** проверка origin/referer на state-changing запросах
- **XSS:** не применимо (внутренний инструмент, HTML генерируется из шаблонов)
---
## 9. Настройки проекта (settings.json)
```json
{
"globalSpacing": 40,
"blocks": {
"Текст": { "spacing": 20, "template": "..." },
"Кнопка": { "spacing": 10 }
},
"genderPaths": {
"headerFemale": "./parts/header/header-woman",
"headerMale": "./parts/header/header-man",
"footerFemale": "./parts/footer/footer-woman",
"footerMale": "./parts/footer/footer-man"
},
"feedUrl": "https://...",
"ftpConfig": {
"host": "...",
"port": 21,
"user": "...",
"password": "***",
"protocol": "ftp",
"remotePath": "/images",
"publicUrl": "https://..."
},
"mixinRules": [
{ "mixin": "buttonRounded", "argIndex": 0, "type": "mixin-text", "label": "Текст кнопки" }
],
"productOptions": [
{ "code": "showPrice:true", "label": "Показывать цену", "defaultEnabled": true }
],
"customBlocks": [
{ "id": "...", "name": "Мой блок", "content": "..." }
]
}
```
---
## 10. email-gen — шаблоны (репозиторий коллег)
### Структура
```
email-gen/emails/vipavenue/
├── layout/layout.pug # Основной layout, table width=600
├── blocks/block.pug # Все блоки (//Текст, //Кнопка, //Товары, etc)
├── includes/mixins.pug # Pug-миксины (+products, +buttonRounded, +preheader, etc)
├── css/style.css # CSS (инлайнится при рендере)
└── parts/
├── header/ # Хедеры (header-woman.pug, header-man.pug, header-dark.pug)
└── footer/ # Футеры (аналогично)
```
### Как добавить новый блок
1. Добавить секцию в `blocks/block.pug`:
```pug
//Мой новый блок
tr
td.paddingWrapper
+defaultTable("100%")
tr
td(align="center")
span.font.h3.blackText Текст блока
```
2. Пересобрать контейнеры: `docker compose build && docker compose up -d`
3. Блок появится в конструкторе
### Preheader
- Миксин `+preheader(text)` в mixins.pug
- Формат: `{текст} <vk-snippet-end>&#10240;×130`
- `#MAILRU_PREHEADER_TAG#` в pug заменяется на `<vk-snippet-end>` в server.js
---
## 11. Деплой
### Сервер
- IP: определяется из конфига
- Проект: `/opt/va/` (или аналогичный путь)
- Nginx: reverse proxy → localhost:6001
### Обновление кода
**Frontend (App.svelte, app.css, lib/*.js):**
```bash
# Файлы примонтированы через том — просто редактируй на хосте
# HMR подхватит автоматически
```
**Backend (vite.config.js):**
```bash
# Файл примонтирован, но Vite не перечитывает конфиг
docker compose restart builder
```
**Шаблоны email (email-gen/):**
```bash
docker compose build && docker compose up -d
```
**email-gen-api/server.js:**
```bash
docker compose build email-gen-api && docker compose up -d email-gen-api
```
### Первый запуск
```bash
git clone <repo>
cd VA.ASPEKTER
docker compose build
docker compose up -d
# Смотреть логи для временного пароля admin:
docker compose logs builder | grep "Временный пароль"
```
---
## 12. Известные особенности и ограничения
### Архитектурные
- **Монолит:** App.svelte ~6400 строк, vite.config.js ~1500 строк. Декомпозиция не проводилась.
- **Файловое хранилище:** нет БД, всё в JSON. Race condition при параллельных записях теоретически возможен (writeFileSync без блокировки).
- **Single-project:** код спроектирован под vipavenue. Мультипроектность убрана, но следы остались (PROJECT_NAME constant).
### Рендер
- **Кэш:** 30 записей LRU. При изменении CSS в email-gen кэш не инвалидируется (нужно очистить вручную или пересобрать).
- **Mindbox-теги:** парсятся regex-ом, не DOM. Если формат тегов изменится — сломается.
- **XML-фид:** парсится regex-ом. CDATA, вложенные теги с одинаковым именем — потенциально проблемны.
### Фронтенд
- **Svelte 5:** используется Svelte 5, но не все runes-паттерны применены. `ensureSchema()` мутирует block.schema напрямую.
- **MutationObserver:** на document.body с subtree=true. Debounce 200ms добавлен, но это всё равно потенциальная нагрузка.
### TODO (отмечено в коде)
- Google Sheets → Yonote API миграция (поиск по `TODO: YONOTE`)
- Пользовательские настройки (тема, zoom) частично перенесены на сервер, localStorage ещё используется как fallback
- Pull email-gen из git — функция запланирована, но не реализована
---
## 13. Conventions
### Именование
- localStorage ключи: `va-*` (va-active-page, va-theme, va-plan-cache, va-previewZoom)
- Cookie: `va_token`
- CSS классы: `va-spell` (spellcheck), `vaclick` (preview detection)
- Docker: `vaaspekter-builder-1`, `vaaspekter-email-gen-api-1`
### Форматирование кода
- 2 пробела отступ
- Одинарные кавычки в JS
- Без точек с запятой (semicolons) — inconsistent, в некоторых местах есть
### Git
- `email-gen/` — это отдельный git-репозиторий коллег, вложен как поддиректория (НЕ submodule)
- Основной репозиторий: `VA.ASPEKTER/`