# Backend: CRUD для контентных сущностей Эта страница объясняет рефакторинг CRUD-эндпоинтов для контентных сущностей backend API. Цель изменений - чтобы backend-разработчик быстро понял, где находится код, как проходит запрос, как добавить новый похожий ресурс и почему пагинация сделана через `Pagerfanta`. ## Задача (#27) Нужно дать API для админ-панели контент-менеджеров ([UI](/apps/admin-panel-content-crud)), чтобы управлять контентом не напрямую через Bitrix, а через backend: - новости; - акции; - заболевания; - центры; - статьи; - услуги. Основные требования: - единый CRUD-подход для всех шести ресурсов; - пагинация в стиле Symfony/проекта, без ручного `LIMIT/OFFSET + COUNT`; - минимум дублирования в контроллерах и сервисах; - запись только для пользователей с `ROLE_ADMIN`; - сохранить существующую синхронизацию из SQL view/Bitrix. ## Ресурсы и маршруты | Сущность | Контроллер | Базовый путь | Read group | Write group | | --- | --- | --- | --- | --- | | `News` | `NewsController` | `/news` | `news:read` | `news:write` | | `Promo` | `PromoController` | `/promo` | `promo:read` | `promo:write` | | `Disease` | `DiseaseController` | `/disease` | `disease:read` | `disease:write` | | `MedicalCenter` | `MedicalCenterController` | `/medical-center` | `medical_center:read` | `medical_center:write` | | `Article` | `ArticleController` | `/article` | `article:read` | `article:write` | | `SiteService` | `SiteServiceController` | `/site-services` | `site_service:read` | `site_service:write` | У всех ресурсов есть одинаковый набор CRUD-маршрутов: | Метод | Путь | Доступ | Назначение | | --- | --- | --- | --- | | `GET` | `/{resource}/list` | публичный | список с пагинацией и фильтрами | | `GET` | `/{resource}/{id}` | публичный | детальная карточка | | `POST` | `/{resource}/create` | `ROLE_ADMIN` | создание | | `PUT` | `/{resource}/{id}` | `ROLE_ADMIN` | обновление | | `DELETE` | `/{resource}/{id}` | `ROLE_ADMIN` | удаление | У `ArticleController` дополнительно есть `GET /article/alias/{alias}`. ## Общая схема ```mermaid flowchart TD Client[HTTP client / admin panel] --> Controller[Content Controller] Controller -->|GET /list| Repository[Repository::createFilteredQueryBuilder] Repository --> Filter[ContentFilterTrait] Filter --> QueryBuilder[Doctrine QueryBuilder] QueryBuilder --> Paginator[Pagination\\Paginator] Paginator --> Pagerfanta[Pagerfanta + QueryAdapter] Pagerfanta --> JsonList[JSON: data + pagination/meta] Controller -->|POST/PUT/DELETE/GET detail| CrudResponder[CrudResponder] CrudResponder --> Serializer[Symfony Serializer groups] CrudResponder --> Validator[Symfony Validator] CrudResponder --> EntityManager[Doctrine EntityManager] EntityManager --> Db[(PostgreSQL)] Commands[Upload*Command] --> SyncService[*CrudService::syncFromView*] SyncService --> Db ``` ## Ключевой принцип Контроллер не должен знать, как парсить JSON, как делать `persist/flush`, как форматировать ошибки валидации и как считать страницы. Контроллер отвечает только за HTTP-маршрут, выбор entity-класса, serializer groups и вызов общего сервиса. ## Тонкий контроллер Пример `NewsController`: ```php #[Route('/news')] final class NewsController extends AbstractController { private const READ_GROUPS = ['news:read']; private const WRITE_GROUPS = ['news:write']; public function __construct( private readonly CrudResponder $crud, private readonly Paginator $paginator, ) { } #[Route('/list', name: 'news_list', methods: ['GET'])] public function list(Request $request, NewsRepository $repository): JsonResponse { $qb = $repository->createFilteredQueryBuilder( ContentFilterDto::fromRequest($request, defaultActive: true), ); return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [ 'groups' => self::READ_GROUPS, ]); } #[Route('/{id}', name: 'news_show', methods: ['GET'], requirements: ['id' => '\\d+'])] public function show(News $news): JsonResponse { return $this->crud->read($news, self::READ_GROUPS); } #[IsGranted('ROLE_ADMIN')] #[Route('/create', name: 'news_create', methods: ['POST'])] public function create(Request $request): JsonResponse { return $this->crud->create($request, News::class, self::WRITE_GROUPS, self::READ_GROUPS); } #[IsGranted('ROLE_ADMIN')] #[Route('/{id}', name: 'news_update', methods: ['PUT'], requirements: ['id' => '\\d+'])] public function update(Request $request, News $news): JsonResponse { return $this->crud->update($request, $news, self::WRITE_GROUPS, self::READ_GROUPS); } #[IsGranted('ROLE_ADMIN')] #[Route('/{id}', name: 'news_delete', methods: ['DELETE'], requirements: ['id' => '\\d+'])] public function delete(News $news): JsonResponse { return $this->crud->delete($news); } } ``` Остальные пять контроллеров устроены так же. Отличаются только: - базовый `#[Route]`; - entity-класс; - repository-класс; - serializer groups. ## Пагинация Пагинация вынесена в `App\Service\Pagination\Paginator`. Причина: в проекте уже есть похожий подход в `PriceListController`: `QueryAdapter` + `Pagerfanta`. Поэтому для новых списков не нужно писать свою пагинацию и отдельный `COUNT`-запрос. Сервис принимает готовый Doctrine `QueryBuilder` и `Request`: ```php public function paginate( QueryBuilder $qb, Request $request, int $defaultPerPage = self::DEFAULT_PER_PAGE, int $maxPerPage = self::MAX_PER_PAGE, ): array ``` Он читает: - `page` - номер страницы, минимум `1`; - `perPage` - размер страницы, минимум `1`, максимум `500`. Формат ответа для `news`, `promo`, `disease`, `medical-center`, `site-services`: ```json { "data": [], "pagination": { "total": 42, "count": 10, "per_page": 10, "current_page": 1, "total_pages": 5, "has_previous_page": false, "has_next_page": true } } ``` Для `article` сохранён старый API-контракт фронтенда: параметр размера страницы называется `limit`, а метаданные лежат в ключе `meta`. ```json { "data": [], "meta": { "total": 42, "page": 1, "limit": 20, "totalPages": 3 } } ``` Это сделано через отдельный метод `Paginator::paginateWithLegacyMeta()`, чтобы не ломать клиентов, которые читают `response.data.meta.total`. Важно: `Paginator` не знает ничего про конкретную сущность. Он работает с любым `QueryBuilder`. ## Фильтрация списков Каждый repository имеет метод: ```php public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder ``` Пример `NewsRepository`: ```php class NewsRepository extends ServiceEntityRepository { use ContentFilterTrait; public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder { $qb = $this->createQueryBuilder('n')->orderBy('n.id', 'DESC'); $this->applyCommonFilters($qb, 'n', $filters); return $qb; } } ``` Общие фильтры лежат в `App\Repository\ContentFilterTrait`. Он подключается в нужные Doctrine-репозитории и не требует статических helper-вызовов. HTTP query-параметры не передаются в repository сырым массивом. Контроллер сначала мапит `Request` в `ContentFilterDto`: ```php // По умолчанию без фильтра по active (как /disease, /article): ContentFilterDto::fromRequest($request) // Легаси: если параметр active не передан, считать active = true (news, promo, medical-center, site-services): ContentFilterDto::fromRequest($request, defaultActive: true) ``` Так слой БД получает типизированный объект с уже разобранными значениями (`?int`, `?bool`, `?string`), а не `array` из HTTP. Нежелательные формы query вроде `?regionId[]=1` или `?active[]=1` отдают Symfony в `get()` как массив: `ContentFilterDto` обрабатывает только scalar-значения для числовых и булевых полей, такие случаи трактуются как «фильтр не задан» и **не** вызывают 500 (TypeError). Поддерживаемые query-параметры: | Параметр | Тип | Что делает | | --- | --- | --- | | `regionId` / `region_id` | integer | фильтр по региону | | `active` | boolean | фильтр активности; **для `/news`, `/promo`, `/medical-center`, `/site-services/list` при отсутствии параметра применяется `active = true`** (легаси). Для `/disease` и `/article` без параметра фильтр по `active` не накладывается | | `alias` | string | точное совпадение alias | | `search` / `q` | string | поиск по `LOWER(name) LIKE ...` | Для больших таблиц под `search` нужен функциональный индекс PostgreSQL по `LOWER(name)`. Если решите заменить это на `ILIKE`, потребуется отдельная Doctrine DQL-функция или переход на native SQL для этого фильтра. Поле, по которому ищем, параметризовано в трейте: `applyCommonFilters($qb, $alias, $filters, $searchField = 'name')`. Все шесть текущих сущностей имеют свойство `$name`, поэтому в репозиториях оно передаётся неявно по умолчанию. Если новая сущность хранит основное название в другом поле (например, `title`), достаточно явно прокинуть его именем в трейт. ### Naming strategy В `config/packages/serializer.yaml` сознательно не настроен `NameConverter`. JSON-ключи запросов и ответов используют **camelCase** ровно так, как названы свойства сущности (`regionId`, `previewPicture`, `updateAt`, …). Если клиент пришлёт snake_case (`region_id`, `preview_picture`), Symfony Serializer молча проигнорирует такие ключи, и поля не запишутся в сущность - это и есть причина, по которой старые `*CrudService` поддерживали оба формата вручную через `array_key_exists`. Теперь поддержка обоих форматов сознательно убрана: клиент должен присылать консистентный camelCase, иначе будет тихая потеря данных. Пример запроса: ```bash curl 'http://localhost:8081/news/list?page=1&perPage=20®ionId=91&active=true&search=акция' ``` ## Create / Update / Delete Общая логика записи вынесена в `App\Service\Crud\CrudResponder`. Что делает `CrudResponder`: - проверяет, что body - JSON-объект (ловит и нативный `\JsonException`, и `Symfony\...\HttpFoundation\Exception\JsonException`); - денормализует payload через `DenormalizerInterface::denormalize($array, $class, null, [...])`, без дополнительного `json_encode/deserialize` round-trip; - использует write-группу сущности, например `news:write`; - удаляет `id` из payload перед денормализацией для create/update; - валидирует entity через Symfony Validator и при ошибке отдаёт **HTTP 400** + сериализованный `ConstraintViolationList` (формат Symfony по умолчанию, RFC 7807 с ключом `violations`) - тот же формат, что отдавали старые `*CrudService`-контроллеры; - делает `persist/flush` для create; - делает `flush` для update; - делает `remove/flush` для delete; при ошибке БД (например, foreign key constraint) ловит `Doctrine\DBAL\Exception` и возвращает `HTTP 500` + `{error, message}` - сохраняем легаси-контракт старого `ArticleController::delete`; - сериализует ответ через read-группу, например `news:read`. ### Контракты ответов на ошибки | Сценарий | HTTP | Тело | | --- | --- | --- | | Тело не JSON-объект | 400 | `{"error": "Ожидается JSON-объект в теле запроса"}` | | Денормализация упала (например, ждали int, прислали object) | 400 | `{"error": "Ошибка десериализации: ..."}` | | Symfony Validator нашёл нарушения | 400 | сериализованный `ConstraintViolationList` (`type`, `title`, `detail`, `violations: [{propertyPath, title, template, parameters}]`) | | Удаление упало в БД (FK / not null / unique) | 500 | `{"error": "Ошибка при удалении записи", "message": "..."}` | | Успешный delete | 204 | пустое тело | Create: ```php return $this->crud->create($request, News::class, ['news:write'], ['news:read']); ``` Update: ```php return $this->crud->update($request, $news, ['news:write'], ['news:read']); ``` Delete: ```php return $this->crud->delete($news); ``` ## Почему не ручной updateEntity Раньше в сервисах были большие методы вида: ```php if (array_key_exists('name', $data)) { $news->setName($data['name']); } if (array_key_exists('regionId', $data) || array_key_exists('region_id', $data)) { $v = $data['regionId'] ?? $data['region_id']; $news->setRegionId($v === null || $v === '' ? null : (int) $v); } ``` Такой подход плохо масштабируется: - одинаковые проверки копируются между сущностями; - легко забыть поле; - легко по-разному обработать один и тот же тип; - controller/service превращается в ручной mapper; - сложнее держать безопасность записи через allowlist. Теперь allowlist полей задаётся serializer groups прямо в entity: ```php #[ORM\Column(type: Types::TEXT, nullable: true)] #[Groups(['news:read', 'news:write'])] private ?string $name = null; #[ORM\GeneratedValue(strategy: "IDENTITY")] #[ORM\Column(type: Types::INTEGER)] #[Groups(['news:read'])] private ?int $id = null; ``` Если поле не входит в `*:write`, клиент не может изменить его через `PUT`. По текущей проверке write-группы у контентных сущностей содержат только scalar/json поля. ORM-связей (`OneToMany`, `ManyToMany` и т.п.) в этих шести сущностях нет. Если такие связи появятся позже, их нельзя автоматически добавлять в `*:write`: сначала нужно отдельно решить, можно ли менять эту связь через публичный/admin JSON API. ## Особенность id Для публичного/admin CRUD `id` должен генерироваться приложением/БД, а не приходить от клиента. У контентных сущностей настроен `GeneratedValue(strategy: "IDENTITY")`, а миграция добавляет sequence/default для таблиц, где id раньше был assigned. Поэтому поведение такое: - на `POST /create` `CrudResponder` по умолчанию удаляет `id` из payload до десериализации; - на `PUT /{id}` `CrudResponder` всегда удаляет `id` из payload до десериализации; - даже если кто-то случайно добавит `id` в `*:write` group, первичный ключ не будет перезаписан через HTTP CRUD. Это защищает от ситуации, когда клиент обновляет `/news/10`, но присылает `"id": 999` и случайно/намеренно меняет первичный ключ. Пример create: ```json { "name": "Новость из админки", "active": true, "regionId": 91, "alias": "admin-news", "anons": "Короткий анонс", "content": "Полный текст" } ``` Пример update: ```json { "id": 123, "name": "Новое название", "active": false } ``` В этом update поле `id` будет проигнорировано. Синхронизация из Bitrix/view остаётся отдельным SQL-процессом: `syncFromView*` по-прежнему вставляет исторические id напрямую через `INSERT ... SELECT ... ON CONFLICT`. PostgreSQL sequence настроена так, чтобы новые id брались выше текущего `MAX(id)`. ## update_at Поле `updateAt` больше не входит в `*:write`, поэтому фронтенд не управляет датой обновления напрямую. Для автоматического заполнения используется Doctrine lifecycle callback: ```php #[ORM\HasLifecycleCallbacks] class News { use UpdateTimestampTrait; } ``` `UpdateTimestampTrait`: - на `PrePersist` ставит текущую метку времени (`\DateTimeImmutable`), если `updateAt` ещё пустой; - на `PreUpdate` обновляет `updateAt` новой `DateTimeImmutable`; - в трейте объявлен `@property \DateTimeInterface|null $updateAt` для статического анализа (само поле остаётся в сущности с `#[Groups]`). Импорт из Bitrix/view продолжает писать `update_at` напрямую SQL-ом и не зависит от HTTP CRUD lifecycle callbacks. ## Синхронизация из Bitrix/view У сервисов `NewsCrudService`, `PromoCrudService`, `DiseaseCrudService`, `MedicalCenterCrudService`, `SiteServiceCrudService` теперь оставлена только ответственность за импорт: - `NewsCrudService::syncFromViewNews()`; - `PromoCrudService::syncFromViewPromo()`; - `DiseaseCrudService::syncFromViewDisease()`; - `MedicalCenterCrudService::syncFromViewCenters()`; - `SiteServiceCrudService::syncFromViewServices()`. Эти методы используются командами: - `UploadNewsCommand`; - `UploadPromoCommand`; - `UploadDiseasesCommand`; - `UploadMedicalCentersCommand`; - `UploadSiteServicesCommand`. Идея разделения: - HTTP CRUD - это controller + `CrudResponder` + `Paginator` + repository; - импорт из внешних view - это sync service + console command. Так backend-разработчику проще понять, где менять API-поведение, а где менять импорт. ## Swagger Контентные маршруты должны попадать в Swagger через `apps/backend/config/packages/nelmio_api_doc.yaml`. Path patterns: ```yaml path_patterns: [ '^/news($|/)', '^/promo($|/)', '^/disease($|/)', '^/medical-center($|/)', '^/article($|/)', '^/site-services($|/)' ] ``` Swagger UI локально: ```text http://localhost:8081/docs ``` OpenAPI JSON: ```text http://localhost:8081/api/doc.json ``` ## Как добавить новый похожий CRUD-ресурс 1. Проверить entity и serializer groups: ```php #[Groups(['example:read', 'example:write'])] private ?string $name = null; #[Groups(['example:read'])] private ?\DateTimeInterface $updateAt = null; #[Groups(['example:read'])] private ?int $id = null; ``` 2. Если у сущности есть `updateAt`, подключить lifecycle callbacks: ```php #[ORM\HasLifecycleCallbacks] class Example { use UpdateTimestampTrait; } ``` 3. В repository добавить `createFilteredQueryBuilder`: ```php public function createFilteredQueryBuilder(ContentFilterDto $filters): QueryBuilder { $qb = $this->createQueryBuilder('e')->orderBy('e.id', 'DESC'); $this->applyCommonFilters($qb, 'e', $filters); return $qb; } ``` 4. В controller подключить `CrudResponder` и `Paginator`. 5. Для list использовать (для легаси-поведения «только активные», если клиент не передал `active`, см. второй аргумент — как у `/news`, `/promo`, `/medical-center`, `/site-services`): ```php $qb = $repository->createFilteredQueryBuilder( ContentFilterDto::fromRequest($request, defaultActive: true), ); return $this->json($this->paginator->paginate($qb, $request), Response::HTTP_OK, [], [ 'groups' => self::READ_GROUPS, ]); ``` Если фильтр по активности по умолчанию не нужен (`/disease`, `/article`), передайте только `$request`. 6. Для write-операций использовать: ```php return $this->crud->create($request, Example::class, self::WRITE_GROUPS, self::READ_GROUPS); return $this->crud->update($request, $example, self::WRITE_GROUPS, self::READ_GROUPS); return $this->crud->delete($example); ``` 7. Добавить `#[OA\RequestBody(... Model(... groups: self::WRITE_GROUPS))]` на `create()` и `update()`. 8. Добавить path pattern в `nelmio_api_doc.yaml`. 9. Проверить маршруты: ```bash php bin/console debug:router --env=dev ``` ## Проверка после изменений Внутри контейнера backend: ```bash cd /var/www/backend composer dump-autoload -o php bin/console cache:clear --env=dev --no-warmup php bin/console cache:warmup --env=dev php bin/console debug:router --env=dev ``` Публичная проверка списка: ```bash curl 'http://localhost:8081/news/list?page=1&perPage=2' ``` Ожидаемый минимум: ```json { "data": [], "pagination": { "total": 0, "count": 0, "per_page": 2, "current_page": 1, "total_pages": 1, "has_previous_page": false, "has_next_page": false } } ``` Проверка legacy-контракта статей: ```bash curl 'http://localhost:8081/article/list?page=1&limit=2' ``` Ожидаемые верхнеуровневые ключи: ```json { "data": [], "meta": { "total": 0, "page": 1, "limit": 2, "totalPages": 1 } } ``` Для `POST`, `PUT`, `DELETE` нужен JWT пользователя с ролью `ROLE_ADMIN`. ## Что не надо возвращать обратно Не стоит возвращать: - отдельные `getPaginatedList()` в каждый CRUD-service; - ручные `findByFilters()` и `countByFilters()` для каждой сущности; - `json_decode` в каждом controller; - большие `updateEntity()` с десятками `array_key_exists`; - ручное изменение `id` в `PUT`. Эти вещи уже закрыты общими классами и serializer groups. ## Ветки Git и MR-файлы Чтобы не смешивать архитектуру и шесть CRUD в одном MR, история разнесена по веткам. Ветка **`issues/27`** — интеграционная (всё сразу), **для новых MR не использовать**. ```text origin/dev └── feature/content-crud-architecture # общая инфраструктура (MR #1) ├── feature/content-crud-news # MR по ресурсу ├── feature/content-crud-promo ├── feature/content-crud-disease ├── feature/content-crud-medical-center ├── feature/content-crud-article └── feature/content-crud-site-services ``` UI админки: [admin-panel-content-crud](/apps/admin-panel-content-crud) — ветка `issues/27-refactor` от `dev`. ### Файлы MR (корень монорепозитория) Сгенерированы скриптом `scripts/generate-backend-mr.sh` (база `origin/dev`, не в git): | MR | diff | HTML | | --- | --- | --- | | Архитектура | `mr-backend-content-crud-architecture.diff` | `MR/mr-backend-content-crud-architecture.html` | | Новости | `mr-backend-content-crud-news.diff` | `MR/mr-backend-content-crud-news.html` | | Промо | `mr-backend-content-crud-promo.diff` | `MR/mr-backend-content-crud-promo.html` | | Заболевания | `mr-backend-content-crud-disease.diff` | `MR/mr-backend-content-crud-disease.html` | | Медцентры | `mr-backend-content-crud-medical-center.diff` | `MR/mr-backend-content-crud-medical-center.html` | | Статьи | `mr-backend-content-crud-article.diff` | `MR/mr-backend-content-crud-article.html` | | Услуги сайта | `mr-backend-content-crud-site-services.diff` | `MR/mr-backend-content-crud-site-services.html` | Пересборка: `./scripts/generate-backend-mr.sh` из корня репозитория. ### MR 1 — `feature/content-crud-architecture` Показать разработчикам **общую доработку** (без контроллеров и без привязки к одной сущности): | Файл | Назначение | | --- | --- | | `src/Dto/Content/ContentFilterDto.php` | query-параметры list (search, page, regionId, active) | | `src/Service/Pagination/Paginator.php` | Pagerfanta, `pagination` / `meta` | | `src/Repository/ContentFilterTrait.php` | фильтры в QueryBuilder | | `migrations/Version20260515142000.php` | `IDENTITY` + `SEQUENCE` / `setval` для контент-таблиц | | `src/Service/Crud/CrudResponder.php` | create/read/update/delete, валидация, denormalize | | `src/Entity/Behavior/UpdateTimestampTrait.php` | `updateAt` на persist | В полной ветке architecture также есть `config/validator/ContentCrud.yaml` и правки всех `Entity` — в **точечных MR по ресурсу** попадают изменения своей сущности (см. ниже). ### MR 2…7 — ветки по ресурсу Частичная реализация **поверх архитектуры**: тонкий контроллер, репозиторий с `ContentFilterTrait`, `*CrudService` (sync из view), правки entity. Пример **новостей** (`feature/content-crud-news`): | Файл | Что в MR | | --- | --- | | `config/packages/nelmio_api_doc.yaml` | только строка `'^/news($|/)'` | | `src/Controller/NewsController.php` | CRUD-маршруты | | `src/Entity/News.php` | trait, groups, `GeneratedValue` | | `src/Repository/NewsRepository.php` | list + фильтры | | `src/Service/NewsCrudService.php` | sync из view | | Ветка | Свои файлы (аналогично news) | | --- | --- | | `feature/content-crud-promo` | `PromoController`, `Promo.php`, `PromoRepository`, `PromoCrudService`, `'^/promo($|/)'` | | `feature/content-crud-disease` | `DiseaseController`, `Disease.php`, `DiseaseRepository`, `DiseaseCrudService`, `'^/disease($|/)'` | | `feature/content-crud-medical-center` | `MedicalCenterController`, `MedicalCenter.php`, `MedicalCenterRepository`, `MedicalCenterCrudService`, `'^/medical-center($|/)'` | | `feature/content-crud-article` | `ArticleController`, `Article.php`, `ArticleRepository`, `'^/article($|/)'` (без отдельного `ArticleCrudService` в ветке) | | `feature/content-crud-site-services` | `SiteServiceController`, `SiteService.php`, `SiteServiceRepository`, `SiteServiceCrudService`, `'^/site-services($|/)'` | Порядок мержа: **architecture** → любые **resource** (параллельно). `issues/27` — только локальная интеграция, не для review.