# teeu — Развёртывание (production)

Гайд для боевого/staging-развёртывания. Dev-окружение (Laragon/Windows) — в
[architecture.md](architecture.md); очереди/планировщик — в [queues.md](queues.md).

## Обновление (redeploy) — рабочий процесс

**Правило: сначала коммит в git, потом выкатка на сервер.** Прод (teeu.ru, HestiaCP,
`/home/teeu/web/teeu.ru/private/teeu`, `public_html`→симлинк на `public`) обновляется скриптом
[`bin/deploy.sh`](../bin/deploy.sh):

```bash
# 1. если менялся фронт — пересобрать ассеты (Node только локально) и закоммитить их:
#    (PowerShell) $env:Path="d:\laragon\bin\nodejs\node-v22;"+$env:Path; npm run build
git add -A && git commit -m "…"
# 2. выкатка:
bash bin/deploy.sh
```

`bin/deploy.sh` пушит HEAD в GitLab и переносит на сервер **только версионируемые файлы**
(`git archive` → `.env` и загрузки в `storage/` не затрагиваются), затем на сервере: `composer install
--no-dev`, `migrate --force`, пересборка кэшей (config/route/view/event + filament), `queue:restart`,
`chown teeu:teeu`. `public/build` **коммитится** в репозиторий (на сервере нет Node).

`optimize:clear` в этой цепочке очищает и кэш приложения, а с ним фильтры и счётчики каталога.
Поэтому последним шагом деплой запускает в фоне `teeu:catalog:warm` — крупные разделы считаются
секундами, и без прогрева этот пересчёт достался бы первым посетителям ([catalog.md](catalog.md)).
Деплой прогрева не ждёт.

> **ВАЖНО:** добавление НОВОГО Tailwind-класса в Blade (в т.ч. arbitrary — `text-[#…]`, `bg-[#…]`,
> `max-w-[42%]`) требует `npm run build` перед коммитом. «Blade-only, без ребилда» верно только если
> переиспользуешь уже существующие классы — иначе класс не попадёт в бандл и стиль молча не применится
> (был баг: кнопка видна лишь при ховере, т.к. `text-white` из hover генерился, а базовый `text-[#…]` —
> нет). Проверять главный CSS через `public/build/manifest.json`, не `ls -t` (там сплит-чанк).

## Требования

- **PHP 8.3** с расширениями: `mbstring`, `openssl`, `pdo_mysql`, `zip` (Filament/openspout),
  `gd` (intervention/image — imagick не требуется), `xml`/`libxml` (YML-парсер, XXE-safe),
  `intl`, `bcmath`, `curl`, `fileinfo`.
- **MySQL 8** (InnoDB, `utf8mb4`).
- **Node 22 + npm** — только для сборки фронтенда на этапе деплоя (в рантайме не нужен).
- Веб-сервер: **nginx + PHP-FPM** (рекомендуется).
- Опционально: Redis (можно перевести `QUEUE_CONNECTION`/`CACHE_STORE` на `redis` без изменения кода).

## Первичная установка

```bash
git clone <repo> /var/www/teeu && cd /var/www/teeu
composer install --no-dev --optimize-autoloader
cp .env.example .env && php artisan key:generate
# заполнить .env: APP_URL, APP_ENV=production, APP_DEBUG=false, DB_*, MAIL_*, ADMIN_PANEL_PREFIX и т.д.
php artisan migrate --force
php artisan db:seed --class=RoleSeeder --force     # роли spatie (buyer/seller/admin)
npm ci && npm run build                            # сборка Vite → public/build
php artisan storage:link                           # публичный доступ к загруженным изображениям
```

Интеграционные секреты (DeepSeek/Telegram) задаются **не в .env**, а в админке `/<ADMIN_PANEL_PREFIX>`
→ Настройки (шифруются в БД, ТЗ §38.1). `.env` даёт только bootstrap-fallback.

## Кэш и оптимизация (каждый деплой)

```bash
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan event:cache
php artisan filament:optimize
php artisan teeu:sitemap:generate     # первичная генерация sitemap
```

При откате/изменении — `php artisan optimize:clear`.

## Очереди (supervisor)

Тяжёлые задачи (YML, AI, images, notifications) идут в БД-очередь — рантайм-воркеры обязательны,
иначе импорт/модерация/уведомления не выполняются. Пример `supervisor` (`/etc/supervisor/conf.d/teeu.conf`):

```ini
[program:teeu-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/teeu/artisan queue:work --queue=ai,images,yml-download,yml-processing,notifications,default --sleep=1 --tries=1 --max-time=3600
directory=/var/www/teeu
autostart=true
autorestart=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/www/teeu/storage/logs/worker.log
stopwaitsecs=3600
```

`--tries=1`: per-job retry/backoff заданы в самих джобах (ТЗ §48.2) — глобальный tries не навязываем.
После деплоя перезапускать воркеры: `php artisan queue:restart`.

> На боевом teeu.ru (Hestia) supervisor не используется — воркер запускается из cron под `flock`
> (`--stop-when-empty --max-time=55`), см. [«Боевой прод (Hestia)»](#боевой-прод-hestia--реальный-crontab-и-gotcha-с-disable_functions).
> Там же — обязательный префикс `-d disable_functions=` (иначе `pcntl_signal()` фаталит).

## Планировщик (cron)

Один cron-entry (пользователь www-data):

```cron
* * * * * cd /var/www/teeu && php artisan schedule:run >> /dev/null 2>&1
```

Список задач — [queues.md](queues.md).

### DNS хостера отдаёт SERVFAIL на отдельные зоны

Симптом со стороны продавца: фид не добавляется, форма пишет, что адрес недопустим. На деле
`SsrfGuard` не смог разрешить имя хоста, а резолверы хостера (85.193.93.194 / .193) отдавали
**SERVFAIL** именно на эту зону — публичные DNS ту же запись возвращали спокойно. DNSSEC ни при чём,
он выключен.

Лечится списком резолверов в `/etc/systemd/resolved.conf.d/99-public-fallback.conf`: хостерские
первыми, следом `1.1.1.1` и `8.8.8.8` — при SERVFAIL systemd-resolved переключается на следующий.
Файл живёт только на сервере, деплой его не восстанавливает, поэтому эталон здесь:

```ini
[Resolve]
DNS=85.193.93.194 85.193.93.193 1.1.1.1 8.8.8.8
```

Если фид снова «недопустим» — проверить `resolvectl query <домен>` на сервере. (Настроено 2026-08-21.)
### Сборка на сервере идёт от пользователя сайта, а не от root

`bin/deploy.sh` заходит по ssh под root, но артизан-команды выполняет через
`sudo -u teeu`. Иначе `view:cache` создаёт скомпилированные шаблоны с владельцем `root`, а php-fpm
работает под `teeu` и перезаписать их не может: Blade падает на `tempnam()`, Laravel превращает
notice в исключение, посетитель видит **500**.

Симптом коварный: ошибка всплывает не сразу и не на всех страницах — только там, где Blade решил
перекомпилировать шаблон. На проде так накопилось 172 ошибки с 5 августа, всплесками ровно по времени
выкаток; заметили, когда одна из них попалась на глаза в админке.

Сначала это чинилось двумя `chown -R` по всему дереву — до сборки и после. Работало, но стоило
дорого: в каталоге проекта 250 тысяч файлов (`vendor`, `storage`), и на них уходило больше времени,
чем на весь остальной деплой — до двадцати минут, пока диск был занят. С 8 сентября 2026 архив
**распаковывается сразу от `teeu`** (`sudo -u teeu tar -x`), root-файлов не появляется вовсе, а права
правятся только там, куда пишет php-fpm — `storage/framework` и `bootstrap/cache`.

На случай, если в дереве кода всё-таки окажется файл от root (ручная правка, наследие старой
выкатки), перед распаковкой стоит дешёвая проверка `find … -not -user teeu -print -quit`: она
останавливается на первой находке и только тогда запускает разовый `chown -R`.

### Что ещё ускоряет выкатку

- **`composer install` пропускается**, если `composer.lock` не изменился. Скрипт снимает md5 до и
  после распаковки; при совпадении шаг выполняется только при отсутствующем `vendor`.
- **Логи крутит logrotate** — конфиг лежит в репозитории (`bin/logrotate-teeu.conf`), ставится один
  раз копированием в `/etc/logrotate.d/teeu`. Без него `storage/logs` разросся до 136 МБ
  (`laravel.log` 55 МБ, `worker-fast.log` 56 МБ), и в него упирался всякий обход каталога. После
  первой ротации осталось 5.7 МБ. `copytruncate` там обязателен: php-fpm и cron-воркеры держат файлы
  открытыми и пишут по дескриптору — обычная ротация оставила бы их писать в невидимый inode.
### Боевой прод (Hestia) — реальный crontab и gotcha с `disable_functions`

На teeu.ru нет supervisor: воркер и планировщик крутятся через **cron юзера `teeu`** (`crontab -u teeu -l`).
crontab **живёт только на сервере** — `bin/deploy.sh`/`git archive` его не трогают и не восстанавливают,
поэтому эталон держим здесь. Пять строк, каждая раз в минуту:

```cron
* * * * * /usr/bin/php8.3 -d disable_functions= /home/teeu/web/teeu.ru/private/teeu/artisan schedule:run >/dev/null 2>&1
# Импорт фидов. Может идти часами на большом фиде.
* * * * * flock -n /tmp/teeu-queue.lock /usr/bin/php8.3 -d disable_functions= /home/teeu/web/teeu.ru/private/teeu/artisan queue:work --stop-when-empty --sleep=1 --tries=1 --max-time=55 --queue=yml-download,yml-processing,default >> /home/teeu/web/teeu.ru/private/teeu/storage/logs/worker.log 2>&1
# AI-модерация: свой процесс и две линии — см. врезку ниже.
* * * * * flock -n /tmp/teeu-queue-ai.lock /usr/bin/php8.3 -d disable_functions= /home/teeu/web/teeu.ru/private/teeu/artisan queue:work --stop-when-empty --sleep=1 --tries=1 --max-time=55 --queue=ai >> /home/teeu/web/teeu.ru/private/teeu/storage/logs/worker-ai.log 2>&1
* * * * * flock -n /tmp/teeu-queue-ai2.lock /usr/bin/php8.3 -d disable_functions= /home/teeu/web/teeu.ru/private/teeu/artisan queue:work --stop-when-empty --sleep=1 --tries=1 --max-time=55 --queue=ai >> /home/teeu/web/teeu.ru/private/teeu/storage/logs/worker-ai.log 2>&1
# Быстрая очередь отдельным процессом — см. врезку ниже.
* * * * * flock -n /tmp/teeu-queue-fast.lock /usr/bin/php8.3 -d disable_functions= /home/teeu/web/teeu.ru/private/teeu/artisan queue:work --stop-when-empty --sleep=1 --tries=2 --max-time=55 --queue=notifications,images >> /home/teeu/web/teeu.ru/private/teeu/storage/logs/worker-fast.log 2>&1
```

> **Почему воркера два.** `queue:work` со списком очередей разбирает их **по приоритету**: пока не
> опустеет первая, до следующих он не доходит. Импорт фида на 9 000 товаров ставит столько же заданий
> AI-модерации, и одним процессом картинки с уведомлениями ждали за ними больше часа — для продавца
> это выглядело как «товары приехали без фото». Быстрая очередь вынесена в отдельный процесс со своим
> lock-файлом; `notifications` стоит перед `images`, потому что письмо покупателю важнее, чем ресайз.
> `--tries=2` там же: сетевая осечка при скачивании картинки не должна стоить фотографии навсегда.
> (Разведено 2026-08-21.)

> **Почему AI вынесена отдельно и в две линии.** Тот же приоритет очередей ударил во второй раз, но
> больнее: `ai` стояла **первой** в списке импортного воркера, и двадцать три тысячи задач модерации
> остановили загрузку фидов на несколько часов — при скорости около пятидесяти задач в минуту очередь
> означала восемь часов простоя. Импорт и модерация теперь не пересекаются вовсе.
> Две линии на `ai`, потому что одна не справляется с хвостом после подключения нескольких крупных
> фидов. Больше двух ставить некуда: на сервере два ядра, и он делит их с placeo — упрёмся в
> процессор, а не в очередь. (Разведено 2026-08-24.)

> **Смежная причина того же затора.** `RunYmlImportJob` не была уникальной, а `teeu:yml:sync-due`
> ставит просроченные фиды каждые десять минут — фид, ждущий в очереди, остаётся просроченным и
> ставится снова. Восемнадцать фидов превратились в семьдесят задач. Исправлено `ShouldBeUnique` по
> номеру фида, подробности — [queues.md](queues.md).

> **КРИТИЧНО — `pcntl_*` в `disable_functions` Hestia CLI-PHP.** И `queue:work`, и **`schedule:run`**
> зовут `pcntl_signal()` (Laravel 11 — при `withoutOverlapping`). В системном CLI-`php.ini` Hestia
> `pcntl_*` отключены → команда падает `Call to undefined function pcntl_signal()`. Поэтому **обе**
> строки префиксятся `php8.3 -d disable_functions=` (пусто = включить всё только для этого вызова;
> системный ini не трогаем). Если забыть на `schedule:run`: он берёт overlap-mutex в `cache_locks`,
> фаталит **до** его освобождения → осиротевший mutex висит 24ч → планировщик молча пропускает команду.
> Симптом: cron жив, воркер жив, но `teeu:yml:sync-due` (и sitemap/stale-reminders/ai:retry) не
> диспатчат ничего сутками; в `laravel.log` — `pcntl_signal()` фаталы (в cron `>/dev/null` их не видно).
> Лечение: добавить префикс + `DELETE FROM cache_locks WHERE key LIKE '%schedule%'`. (Инцидент 2026-07-13.)

## Админ-контролы в `/developer` (Filament)

Помимо интеграций (DeepSeek/Telegram) в панели есть:
- **Настройки → «Код на сайте»** — поля `site.head_meta` (в `<head>` на всех страницах, включая
  админку) и `site.body_scripts` (перед `</body>` на всех страницах сайта, **кроме админки**) для
  счётчиков/верификации. Вставляются как есть — только доверенный код.
- **AI-описания** — тумблер обособленного воркера `ai.rewrite.enabled` + размер пачки, и статистика
  (счётчики + последние 10 обработанных). Включил → воркер сам начинает обрабатывать по расписанию.
- **Характеристики (фильтры)** — курирование фасетов (тумблер `is_filterable`, тип, порядок, слияние).
  После деплоя фильтров разово прогнать бэкофилл: `sudo -u teeu php8.3 artisan teeu:attributes:reindex`
  (см. [catalog.md](catalog.md)).
- **Пользователи** — выдача/снятие прав администратора другим аккаунтам; **пользователь #1
  (`admin@teeu.ru`) защищён** — снять с него права нельзя (маркетплейс не остаётся без владельца).
- **Лендинг Korzilla** (`/korzilla-partner`, непубличный — адрес только в письмах, noindex): тексты
  блоков и код формы Битрикс24 — в «Настройки → Лендинг Korzilla», вопросы — в «Контент → Вопрос-ответ
  Korzilla». Подробности — [seller.md](seller.md).
- Настройки шифруются/хранятся в БД (`app_settings`, `SettingsService`), кэш сбрасывается при сохранении.

## Локализация

Локаль сайта — `ru` (`APP_LOCALE=ru`). Переводы строк фреймворка (пагинация, дефолтные сообщения
валидации, футер писем) — в `lang/ru.json` + `lang/ru/{pagination,validation}.php`. Без них строки
падали в англ. ключи («Showing 1 to 24 of 999 results»). Blade-текст сайта захардкожен по-русски;
Filament тянет свои встроенные ru-переводы.

## nginx (существенное)

- `root` → `/var/www/teeu/public`; стандартный Laravel `try_files $uri $uri/ /index.php?$query_string`.
- Отдавать статику из `public/` напрямую; `/sw.js`, `/manifest.webmanifest`, `/robots.txt`,
  `/sitemap*.xml` — динамические маршруты Laravel (в `public/` не класть статические дубли,
  иначе они затенят маршруты; `public/robots.txt` намеренно удалён — см. §41.6).
- `client_max_body_size` — с запасом под загрузку логотипов/изображений (напр. 20m).
- HTTPS обязателен (Service Worker и Web Push работают только по TLS).

## Безопасность на проде (ТЗ §47)

- `APP_DEBUG=false`, `APP_ENV=production`.
- Права: `storage/` и `bootstrap/cache/` — запись для www-data; остальное read-only.
- Секреты только в БД (encrypted) или в защищённом `.env` (не в VCS).
- Панели `/account`, `/<SELLER_PANEL_PREFIX>`, `/<ADMIN_PANEL_PREFIX>` закрыты авторизацией
  (backend, не только UI) + `noindex` + `Disallow` в robots.
- Slug админ-панели (`ADMIN_PANEL_PREFIX`) — не средство защиты, а лишь снижение шума.

## Проверка после деплоя

```bash
php artisan about
php artisan migrate:status
curl -sI https://<host>/sw.js | grep -i service-worker-allowed
curl -s https://<host>/robots.txt | head
curl -s https://<host>/sitemap.xml | head
```

Плюс: логин в админку, тестовые кнопки DeepSeek/Telegram (Настройки), пробный YML-импорт,
оформление тестового заказа.

Если в выкатке есть правка контента, который владелец уже правил в панели (справка, лендинг `/sell`,
вопросы-ответы), её сидер запускается на проде вручную после деплоя и выводит, что пропустил, — порядок
и правила в [seller.md](seller.md#правка-контента-который-владелец-уже-правил-на-проде).

## Ресурсы сервера и что с ними делать

Прод — 2 ядра, 4 ГБ, KVM. Сервер делит ресурсы с соседним проектом (placeo).

**Своп обязателен, и он есть: 2 ГБ файлом** (`/swapfile`, прописан в `/etc/fstab`,
`vm.swappiness = 10` в `/etc/sysctl.d/99-teeu-swap.conf`). Без свопа любой пик памяти —
крупный импорт вместе с очередью AI — заканчивался бы тем, что ядро убивает самый жирный
процесс, а это MySQL. При swappiness 10 свопа почти не касаются: он страховка, а не рабочее
хранилище. Со значением по умолчанию (60) ядро начало бы выгружать страницы MySQL, и стало бы
медленнее, чем без свопа вовсе.

**Промежуточные данные импорта чистятся ежедневно** (`teeu:yml:prune-staging`, 04:40).
`yml_import_offers` копилась молча и дошла до 1,36 ГБ при базе 1,85 ГБ — после чистки таблица
258 МБ, база 754 МБ. Важен не диск, а кеш MySQL: `innodb_buffer_pool` = 128 МБ, и раздутая база
в него не помещалась, поэтому почти каждый запрос шёл на диск.

Место после `DELETE` InnoDB файловой системе не возвращает — оно остаётся «дырами» внутри файла
таблицы. Разовую чистку большого объёма нужно завершать пересборкой таблицы
(`OPTIMIZE TABLE`, для InnoDB это recreate + analyze).

**Когда пора увеличивать сервер** — по порогам, а не по календарю:

| Признак | Порог | Как проявится |
|---|---|---|
| load average | устойчиво выше 2.0 на 2 ядрах | медленный импорт, подвисания админки |
| доступная память | регулярно ниже 500 МБ | процесс убит OOM-killer'ом |
| диск | выше 70% | импорт падает на записи |

Ориентир по росту: 41 магазин — это около 157 тысяч товаров, примерно 3 800 на магазин. На сотне
магазинов выйдет ~380 тысяч товаров и база около 4 ГБ; там понадобятся 8 ГБ и 4 ядра.

**Резерв, который стоит забрать раньше апгрейда:** `clamav` держит около 959 МБ, `spamd` ещё
около 256 МБ. Это почтовые службы Hestia; если через сервер не ходит почта, требующая проверки,
их отключение даёт больше памяти, чем переход на 6 ГБ.