Files
Gnuboard7/app/Http/Controllers/Api/Admin/LayoutExtensionController.php
HeuJung b5c0ec1178 fix(core,ecommerce,board,page,admin_basic,basic): 목록 응답의 하위 컬렉션 전량 직렬화 해소
관리자 상품목록을 한 페이지 여는 것만으로 그 페이지 모든 상품의 옵션이 응답에 실렸다.
같은 패턴을 저장소 전역에서 찾아 14개 목록 엔드포인트를 함께 정리했다.

근본 원인은 둘이다. Resource 가 whenLoaded 로 방어하는데 Repository 가 목록 쿼리에서
관계를 무조건 로드해 가드가 항상 참이 되는 가짜 가드, 그리고 toListArray 경량 표현을
정의해 두고도 컬렉션이 toArray 를 부르는 목록/상세 미분리다. 둘 다 응답만 보면
정상이라 오류도 경고도 없이 페이로드만 불어난다.

목록은 화면이 실제로 그리는 것만 싣는다. 개수·합계는 PHP 컬렉션 연산이 아니라 DB
집계로, 대표 1건이 필요한 곳은 관계 자체를 oldestOfMany 로 좁힌다. eager load 의
limit(1) 은 부모별이 아니라 배치 쿼리 전체에 걸려 첫 행만 값을 갖게 되므로 쓸 수 없다.

뺀 값에는 대체 경로를 먼저 만들었다. 상품 옵션은 행을 펼칠 때 배치로 불러오고(상품 수와
무관하게 쿼리 상수), 종전 동작이 필요한 호출자를 위해 ?with_options=1 등 opt-in 을 남겼다.
배송정책 국가설정과 리뷰 첨부 이미지는 소비처를 실측한 결과 화면이 실제로 그리고 있어
제거하지 않았다 — 그 소비 사실을 회귀 테스트로 고정했다.

재발 방지로 정적 검사 룰 4종과 규정 문서 항목을 함께 넣었다.
2026-08-05 21:20:05 +09:00

331 lines
12 KiB
PHP

<?php
namespace App\Http\Controllers\Api\Admin;
use App\Exceptions\ConcurrentModificationException;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Admin\LayoutVersionListRequest;
use App\Http\Requests\Layout\StoreLayoutExtensionPreviewRequest;
use App\Http\Requests\Layout\UpdateLayoutExtensionContentRequest;
use App\Http\Resources\LayoutExtensionResource;
use App\Http\Resources\LayoutExtensionVersionResource;
use App\Models\LayoutExtension;
use App\Services\LayoutExtensionService;
use App\Services\LayoutPreviewService;
use App\Services\TemplateService;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
/**
* 레이아웃 확장 관리 컨트롤러 (admin)
*
* 모듈/플러그인이 주입한 레이아웃 확장을 관리자가 편집·버전관리·미리보기할 수 있도록 합니다.
* 확장은 동일 target_name 에 여러 source 가 존재할 수 있어 정수 PK(extensionId)로 식별합니다.
*/
class LayoutExtensionController extends AdminBaseController
{
public function __construct(
private LayoutExtensionService $layoutExtensionService,
private TemplateService $templateService,
private LayoutPreviewService $layoutPreviewService
) {
parent::__construct();
}
/**
* 특정 템플릿의 레이아웃 확장 목록 조회 (출처별 그룹핑)
*
* @param string $templateName 템플릿 identifier
* @return JsonResponse 출처별 그룹핑된 확장 목록
*/
public function index(string $templateName): JsonResponse
{
$template = $this->templateService->findByIdentifier($templateName);
if (! $template) {
return $this->notFound('common.not_found');
}
$groups = $this->layoutExtensionService->getExtensionsByTemplateId($template->id);
// 그룹별 extensions 를 LayoutExtensionResource 컬렉션으로 직렬화.
// 각 확장에 호스트 레이아웃 목록(host_layouts)을 부착해, 라우트 트리가 클릭(캔버스 로드)
// 없이도 layoutName 매칭으로 화면별 연결 확장 목록을 정적 구성하게 한다.
$data = array_map(function (array $group): array {
foreach ($group['extensions'] as $extension) {
$extension->setAttribute(
'host_layouts',
$this->layoutExtensionService->getExtensionHostLayouts($extension)
);
}
// 목록은 경량 표현만 내려준다 — 편집 본문(content)은 단건 조회가 공급한다.
$group['extensions'] = collect($group['extensions'])
->map(fn ($extension) => (new LayoutExtensionResource($extension))->toListArray(request()))
->values()
->all();
return $group;
}, $groups);
return $this->success('common.success', $data);
}
/**
* 특정 레이아웃 확장 상세 조회
*
* @param string $templateName 템플릿 identifier
* @param int $extensionId 확장 ID
* @return JsonResponse 확장 상세 응답
*/
public function show(string $templateName, int $extensionId): JsonResponse
{
$extension = $this->resolveExtension($templateName, $extensionId);
if (! $extension) {
return $this->notFound('common.not_found');
}
// 호스트 레이아웃 후보 — 확장 편집 모드 캔버스가 호스트 병합
// 렌더할 대상. overlay = [target_layout], extension_point = 그 확장점을 포함하는
// 레이아웃 전체(복수면 클라이언트가 대표 호스트 선택 picker 를 띄운다).
// ResponseHelper::success 는 Resource additional() 을 보존하지 않으므로 data 안에 병합.
$payload = (new LayoutExtensionResource($extension))->resolve();
$payload['host_layouts'] = $this->layoutExtensionService->getExtensionHostLayouts($extension);
return $this->success('common.success', $payload);
}
/**
* 레이아웃 확장 content 수정
*
* @param UpdateLayoutExtensionContentRequest $request 검증된 요청
* @param string $templateName 템플릿 identifier
* @param int $extensionId 확장 ID
* @return JsonResponse 업데이트된 확장 응답
*/
public function update(UpdateLayoutExtensionContentRequest $request, string $templateName, int $extensionId): JsonResponse
{
$extension = $this->resolveExtension($templateName, $extensionId);
if (! $extension) {
return $this->notFound('common.not_found');
}
try {
DB::beginTransaction();
// content 는 input() 으로 전체 배열을 전달한다.
// validated() 는 content.* 하위 규칙이 있을 경우 하위 키만 재구성하여
// extension_point/components 등 최상위 키가 누락되기 때문이다.
$data = [
'content' => $request->input('content'),
];
if ($request->has('priority')) {
$data['priority'] = $request->input('priority');
}
if ($request->has('expected_lock_version')) {
$data['expected_lock_version'] = $request->input('expected_lock_version');
}
$updated = $this->layoutExtensionService->updateExtension($extensionId, $data);
DB::commit();
return $this->success('common.success', new LayoutExtensionResource($updated));
} catch (ModelNotFoundException) {
DB::rollBack();
return $this->notFound('common.not_found');
} catch (ConcurrentModificationException $e) {
DB::rollBack();
return $this->error(
'exceptions.concurrent_modification',
409,
[
'error' => 'concurrent_modification',
'current_version' => $e->currentVersion,
'your_version' => $e->expectedVersion,
'resource' => $e->resource,
],
['resource' => $e->resource, 'current' => $e->currentVersion, 'expected' => $e->expectedVersion],
);
} catch (\Exception $e) {
DB::rollBack();
return $this->error('common.failed', 500, ['error' => $e->getMessage()]);
}
}
/**
* 레이아웃 확장의 모든 버전 목록 조회
*
* @param LayoutVersionListRequest $request 버전 목록 조회 요청 (limit)
* @param string $templateName 템플릿 identifier
* @param int $extensionId 확장 ID
* @return JsonResponse 버전 목록 응답
*/
public function versions(LayoutVersionListRequest $request, string $templateName, int $extensionId): JsonResponse
{
$extension = $this->resolveExtension($templateName, $extensionId);
if (! $extension) {
return $this->notFound('common.not_found');
}
$versions = $this->layoutExtensionService->getExtensionVersions(
$extensionId,
(int) ($request->validated()['limit'] ?? LayoutVersionListRequest::DEFAULT_LIMIT)
);
// 목록은 경량 표현만 내려준다 — 확장 본문은 버전 비교 diff 전용이라 단건 조회가 공급한다.
$items = $versions
->map(fn ($version) => (new LayoutExtensionVersionResource($version))->toListArray($request))
->values()
->all();
return $this->success('common.success', $items);
}
/**
* 특정 버전의 레이아웃 확장 content 조회
*
* @param string $templateName 템플릿 identifier
* @param int $extensionId 확장 ID
* @param int $version 버전 번호
* @return JsonResponse 특정 버전 응답
*/
public function showVersion(string $templateName, int $extensionId, int $version): JsonResponse
{
$extension = $this->resolveExtension($templateName, $extensionId);
if (! $extension) {
return $this->notFound('common.not_found');
}
try {
$extensionVersion = $this->layoutExtensionService->getExtensionVersion($extensionId, $version);
} catch (ModelNotFoundException) {
return $this->notFound('common.not_found');
}
return $this->success('common.success', new LayoutExtensionVersionResource($extensionVersion));
}
/**
* 레이아웃 확장 버전 복원
*
* @param string $templateName 템플릿 identifier
* @param int $extensionId 확장 ID
* @param int $versionId 복원할 버전 ID
* @return JsonResponse 복원 후 새 버전 응답
*/
public function restoreVersion(string $templateName, int $extensionId, int $versionId): JsonResponse
{
$extension = $this->resolveExtension($templateName, $extensionId);
if (! $extension) {
return $this->notFound('common.not_found');
}
try {
DB::beginTransaction();
$newVersion = $this->layoutExtensionService->restoreExtensionVersion($extensionId, $versionId);
DB::commit();
return $this->success('common.success', new LayoutExtensionVersionResource($newVersion));
} catch (ModelNotFoundException) {
DB::rollBack();
return $this->notFound('common.not_found');
} catch (\Exception $e) {
DB::rollBack();
return $this->error('common.failed', 500, ['error' => $e->getMessage()]);
}
}
/**
* 레이아웃 확장 미리보기 생성
*
* 편집 중인 확장 content를 임시 저장하고, 대표 레이아웃에 적용한 미리보기 URL을 반환합니다.
*
* @param StoreLayoutExtensionPreviewRequest $request 검증된 요청
* @param string $templateName 템플릿 identifier
* @param int $extensionId 확장 ID
* @return JsonResponse 미리보기 토큰 응답
*/
public function storePreview(StoreLayoutExtensionPreviewRequest $request, string $templateName, int $extensionId): JsonResponse
{
$extension = $this->resolveExtension($templateName, $extensionId);
if (! $extension) {
return $this->notFound('common.not_found');
}
// 대표 레이아웃 결정:
// - overlay 타입은 target_name 자체가 대표 레이아웃
// - extension_point 타입은 프론트가 preview_layout 으로 전달
$previewLayout = $request->validated('preview_layout');
if (! $previewLayout) {
$previewLayout = $extension->extension_type->value === 'overlay'
? $extension->target_name
: null;
}
if (! $previewLayout) {
return $this->error('validation.layout_extension.preview_layout.required', 422);
}
try {
$preview = $this->layoutPreviewService->createExtensionPreview(
$extension->template_id,
$extensionId,
$previewLayout,
$request->validated('content'),
$request->user()->id
);
return $this->success('common.success', [
'token' => $preview->token,
'preview_url' => '/preview/'.$preview->token,
'expires_at' => $preview->expires_at->toIso8601String(),
]);
} catch (\Exception $e) {
return $this->error('common.failed', 500, ['error' => $e->getMessage()]);
}
}
/**
* 템플릿 식별자와 확장 ID 로 확장을 조회하고, 템플릿 소속을 교차검증합니다.
*
* @param string $templateName 템플릿 identifier
* @param int $extensionId 확장 ID
* @return LayoutExtension|null 확장 모델 또는 null (템플릿 미존재 / 확장 미존재 / 소속 불일치)
*/
private function resolveExtension(string $templateName, int $extensionId): ?LayoutExtension
{
$template = $this->templateService->findByIdentifier($templateName);
if (! $template) {
return null;
}
try {
$extension = $this->layoutExtensionService->getExtensionById($extensionId);
} catch (ModelNotFoundException) {
return null;
}
// 확장이 요청 템플릿에 속하는지 교차검증
if ($extension->template_id !== $template->id) {
return null;
}
return $extension;
}
}