674 lines
29 KiB
Markdown
674 lines
29 KiB
Markdown
# 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<string, mixed>` из 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.
|