feat: migrate to VitePress from monorepo docs, add test-contour section
This commit is contained in:
@@ -0,0 +1,673 @@
|
||||
# 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.
|
||||
Reference in New Issue
Block a user