# PostgreSQL, Docker и серверная история чатов Дата: 2026-07-26 ## Цель Заменить устройство-локальное хранение пользователей, истории чатов и выбранной модели на PostgreSQL. Приложение должно запускаться вместе с базой через Docker Compose, а вход и API чата должны работать с данными реального пользователя из базы. ## Инфраструктура `docker-compose.yml` определяет два сервиса: - `db`: PostgreSQL 16, доступный с хоста на `5433:5432`; - `app`: Next.js-сервис, собранный из локального `Dockerfile` и доступный на `3000:3000`. Параметры PostgreSQL фиксированы: ```dotenv POSTGRES_USER=ai-control-chat-ui POSTGRES_PASSWORD=ai-control-chat-ui POSTGRES_DB=ai_control_chat_ui DATABASE_URL=postgresql://ai-control-chat-ui:ai-control-chat-ui@db:5432/ai_control_chat_ui ``` `db` получает healthcheck через `pg_isready`. `app` запускается после перехода базы в healthy-состояние. `Dockerfile` использует многоступенчатую сборку Node.js 22 Alpine. Next.js собирается с `output: "standalone"`; production-образ содержит standalone-сервер, публичные файлы, миграции и скрипты запуска, но не исходные dev-зависимости. ## Миграции и тестовый пользователь Миграции расположены в `db/migrations`, имеют монотонные имена `001_*.sql`, и применяются Node-скриптом в транзакции. Таблица `schema_migrations` хранит имена уже применённых файлов; повторный запуск не изменяет уже применённую схему. Перед запуском приложения контейнер выполняет миграции и безопасный idempotent seed. Seed создаёт или обновляет тестового пользователя: ```text email: demo@ai-control.local password: Demo1234! ``` Пароль сохраняется только как bcrypt-хеш. В production пароль seed-пользователя переопределяется через `AUTH_PASSWORD`; значение по умолчанию предназначено только для локального Docker-сценария. ## Схема данных ### users | Поле | Тип | Ограничение | | --- | --- | --- | | id | UUID | primary key | | email | text | unique, not null | | password_hash | text | not null | | last_model_id | text | not null, default `chatgpt`, check: `chatgpt`, `deepseek`, `qwen` | | created_at | timestamptz | not null | | updated_at | timestamptz | not null | ### conversations | Поле | Тип | Ограничение | | --- | --- | --- | | id | UUID | primary key | | user_id | UUID | foreign key to `users`, cascade delete | | title | text | not null | | model_id | text | valid model check | | created_at | timestamptz | not null | | updated_at | timestamptz | not null | Индекс `conversations(user_id, updated_at desc)` служит загрузке боковой панели. ### chat_messages | Поле | Тип | Ограничение | | --- | --- | --- | | id | UUID | primary key | | conversation_id | UUID | foreign key to `conversations`, cascade delete | | role | text | `user` или `assistant` | | content | text | not null | | status | text | `streaming`, `complete`, `error`, `stopped` | | created_at | timestamptz | not null | Индекс `chat_messages(conversation_id, created_at)` служит загрузке истории и построению контекста. ## Серверная архитектура `lib/db` содержит единственный `pg.Pool`, репозитории и транзакционные функции. SQL всегда параметризован; Route Handlers не содержат SQL-строк. Репозитории: - `users`: поиск по email/ID, bcrypt-проверка пароля, чтение и обновление `last_model_id`; - `conversations`: создание, загрузка только диалогов владельца, удаление и обновление модели/заголовка; - `messages`: добавление пользовательского сообщения, создание assistant-shell, дописывание потока и финализация статуса. В тестах репозитории получают зависимость `Queryable`, что позволяет проверять SQL-логику без реальной сети PostgreSQL. Интеграционный Docker smoke-сценарий проверяет миграции и подключение к настоящей базе. ## Аутентификация `POST /api/auth/login` получает email и пароль, запрашивает пользователя в PostgreSQL и сравнивает пароль с bcrypt-хешем. При успехе JWT подписывается существующим `JWT_SECRET`; `sub` — UUID строки `users.id`, `email` — email из базы. `GET /api/auth/me` проверяет Bearer JWT и дополнительно читает пользователя по `sub`. Удалённый пользователь или несовпадение email возвращает `401`. Никакие логины и пароли не поступают в браузер кроме введённого пользователем пароля при входе. JWT продолжает жить 24 часа и передаётся только как `Authorization: Bearer `. ## API чатов ### GET /api/conversations Возвращает диалоги текущего пользователя с сообщениями, отсортированные по `updated_at` по убыванию. Перед возвратом любые сохранённые `streaming`-сообщения переводятся в `stopped`, чтобы после перезапуска не возникал вечный индикатор генерации. ### PATCH /api/me/model Принимает `{ model: ModelId }`, проверяет модель и сохраняет `users.last_model_id`. Значение применяется к следующему новому чату и восстанавливается после перезагрузки. ### POST /api/chat Принимает только: ```ts { conversationId: string; model: "chatgpt" | "deepseek" | "qwen"; content?: string; retry?: boolean; } ``` Для обычного сообщения сервер проверяет, что диалог принадлежит текущему пользователю, создаёт диалог при новом UUID, сохраняет пользовательское сообщение, строит серверный контекст в пределах 100 сообщений/32 000 символов на сообщение/128 000 символов суммарно и запускает Gateway. Для retry сервер использует только последнее валидное пользовательское сообщение перед финальным `error`/`stopped` assistant-сообщением. Новой пользовательской записи не создаётся. Перед отдачей потока сервер создаёт assistant-shell со статусом `streaming`. Поток одновременно передаётся клиенту и накапливается на сервере. При нормальном окончании shell обновляется до `complete`; при отмене — до `stopped`; при ошибке Gateway — до `error` с безопасным текстом. Изменение `updated_at` и выбранной модели диалога выполняется в той же транзакции, что и создание сообщения. Gateway получает только нормализованные сообщения и отдельный `AI_GATEWAY_API_KEY`. JWT клиента в Gateway не передаётся. ## Клиент После успешной проверки JWT `AuthGate` вызывает `GET /api/conversations`, а хук чата использует сервер как единственный источник истины. Локальный storage больше не содержит историю чатов и модель; его прежние записи игнорируются. При выборе модели клиент оптимистично обновляет UI и вызывает `PATCH /api/me/model`. Создание, удаление, отправка и retry изменяют UI оптимистично, затем синхронизируются с API; при ошибке происходит контролируемый reload истории. ## Ошибки и безопасность - SQL-ошибки, URL базы, хеши паролей, ключи Gateway и внутренние исключения не возвращаются клиенту. - Несуществующий или чужой `conversationId` возвращает `404`, не раскрывая данные другого пользователя. - Отсутствующий/некорректный JWT возвращает `401`. - Некорректная модель, UUID, тело, лимит сообщения или retry-состояние возвращают `400`. - Временная недоступность PostgreSQL возвращает безопасный `503`. - Docker Compose не публикует PostgreSQL на `5432`; с хоста он доступен только на `5433`. ## Проверка качества - Unit-тесты репозиториев, мигратора, bcrypt-входа, ownership API и server-side context window. - Route-тесты для входа, `/api/me`, `/api/conversations`, модели и стриминга чата. - Контейнерный smoke: `docker compose up --build`, healthcheck БД, миграции/seed, успешный вход и сохранение диалога. - Перед handoff: `npm test`, `npm run lint`, `npx tsc --noEmit`, `npm run build` и `docker compose config`. ## Критерии готовности - `docker compose up --build` поднимает приложение и PostgreSQL с БД на `localhost:5433`. - Тестовый пользователь проходит вход через данные из PostgreSQL. - После перезапуска приложения история чатов и последняя модель восстанавливаются из базы. - Пользователь не может прочитать, изменить или отправить сообщение в чужой диалог. - Потоковые ответы и их финальные статусы сохраняются сервером. - JWT и ключи не попадают в PostgreSQL-логи, клиентский код или Gateway-запросы.