v7.0.0-beta.1 release

This commit is contained in:
HeuJung committed 2026-04-01 10:30:52 +09:00
commit 6595fd0eb5
3967 files changed
+1875383

No files matched your search

@@ -0,0 +1,96 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Helpers\PermissionHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\ActivityLog\ActivityLogBulkDeleteRequest;
use App\Http\Requests\ActivityLog\ActivityLogDeleteRequest;
use App\Http\Requests\ActivityLog\ActivityLogIndexRequest;
use App\Http\Resources\ActivityLogCollection;
use App\Models\ActivityLog;
use App\Services\ActivityLogService;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
/**
* 관리자용 활동 로그 컨트롤러
*
* 관리자가 시스템 활동 로그를 조회하고 삭제할 수 있는 기능을 제공합니다.
*/
class ActivityLogController extends AdminBaseController
{
public function __construct(
private ActivityLogService $activityLogService
) {
parent::__construct();
}
/**
* 페이지네이션된 활동 로그 목록을 조회합니다.
*
* @param ActivityLogIndexRequest $request 검증된 요청 객체
* @return JsonResponse 활동 로그 목록을 포함한 JSON 응답
*/
public function index(ActivityLogIndexRequest $request): JsonResponse
{
try {
$filters = array_filter($request->validated(), fn ($value) => $value !== null);
$logs = $this->activityLogService->getList($filters);
$collection = new ActivityLogCollection($logs);
$responseData = $collection->toArray($request);
// 컬렉션 레벨 abilities (페이지 레벨 버튼 제어용)
$responseData['abilities'] = [
'can_delete' => PermissionHelper::check('core.activities.delete', $request->user()),
];
return $this->success('activity_log.fetch_success', $responseData);
} catch (Exception $e) {
return $this->error('activity_log.fetch_failed', 500, $e->getMessage());
}
}
/**
* 활동 로그를 삭제합니다.
*
* @param ActivityLogDeleteRequest $request 검증된 삭제 요청
* @param ActivityLog $activityLog 삭제할 활동 로그 모델
* @return JsonResponse 삭제 결과
*/
public function destroy(ActivityLogDeleteRequest $request, ActivityLog $activityLog): JsonResponse
{
try {
$this->activityLogService->delete($activityLog->id);
return $this->success('activity_log.delete_success');
} catch (Exception $e) {
Log::error('활동 로그 삭제 실패', ['id' => $activityLog->id, 'error' => $e->getMessage()]);
return $this->error('activity_log.delete_failed', 500);
}
}
/**
* 활동 로그를 일괄 삭제합니다.
*
* @param ActivityLogBulkDeleteRequest $request 검증된 일괄 삭제 요청
* @return JsonResponse 삭제 결과
*/
public function bulkDestroy(ActivityLogBulkDeleteRequest $request): JsonResponse
{
try {
$count = $this->activityLogService->deleteMany($request->validated('ids'));
return $this->success('activity_log.bulk_delete_success', ['deleted_count' => $count]);
} catch (Exception $e) {
Log::error('활동 로그 일괄 삭제 실패', ['error' => $e->getMessage()]);
return $this->error('activity_log.delete_failed', 500);
}
}
}
@@ -0,0 +1,134 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Enums\AttachmentSourceType;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Attachment\ReorderAttachmentsRequest;
use App\Http\Requests\Attachment\UploadAttachmentRequest;
use App\Http\Requests\Attachment\UploadBatchAttachmentRequest;
use App\Http\Resources\AttachmentResource;
use App\Models\Attachment;
use App\Services\AttachmentService;
use Exception;
use Illuminate\Http\JsonResponse;
/**
* 관리자용 첨부파일 컨트롤러
*/
class AttachmentController extends AdminBaseController
{
/**
* AttachmentController 생성자
*
* @param AttachmentService $attachmentService 첨부파일 서비스
*/
public function __construct(
private AttachmentService $attachmentService
) {
parent::__construct();
}
/**
* 단일 파일 업로드
*
* @param UploadAttachmentRequest $request 업로드 요청
* @return JsonResponse
*/
public function upload(UploadAttachmentRequest $request): JsonResponse
{
try {
$validated = $request->validated();
$attachment = $this->attachmentService->upload(
file: $request->file('file'),
attachmentableType: $validated['attachmentable_type'] ?? null,
attachmentableId: $validated['attachmentable_id'] ?? null,
collection: $validated['collection'] ?? 'default',
sourceType: isset($validated['source_type'])
? AttachmentSourceType::from($validated['source_type'])
: AttachmentSourceType::Core,
sourceIdentifier: $validated['source_identifier'] ?? null,
);
return $this->successWithResource(
'attachment.upload_success',
new AttachmentResource($attachment),
201
);
} catch (Exception $e) {
return $this->error('attachment.upload_failed', 500, $e->getMessage());
}
}
/**
* 여러 파일 일괄 업로드
*
* @param UploadBatchAttachmentRequest $request 일괄 업로드 요청
* @return JsonResponse
*/
public function uploadBatch(UploadBatchAttachmentRequest $request): JsonResponse
{
try {
$validated = $request->validated();
$attachments = $this->attachmentService->uploadBatch(
files: $request->file('files'),
attachmentableType: $validated['attachmentable_type'] ?? null,
attachmentableId: $validated['attachmentable_id'] ?? null,
collection: $validated['collection'] ?? 'default',
sourceType: isset($validated['source_type'])
? AttachmentSourceType::from($validated['source_type'])
: AttachmentSourceType::Core,
sourceIdentifier: $validated['source_identifier'] ?? null,
);
return $this->successWithResource(
'attachment.upload_batch_success',
AttachmentResource::collection($attachments),
201
);
} catch (Exception $e) {
return $this->error('attachment.upload_failed', 500, $e->getMessage());
}
}
/**
* 첨부파일 삭제
*
* @param Attachment $attachment 첨부파일 (라우트 모델 바인딩)
* @return JsonResponse
*/
public function destroy(Attachment $attachment): JsonResponse
{
try {
$result = $this->attachmentService->delete($attachment->id);
if (! $result) {
return $this->error('attachment.delete_failed');
}
return $this->success('attachment.delete_success');
} catch (Exception $e) {
return $this->error('attachment.delete_failed', 500, $e->getMessage());
}
}
/**
* 순서 변경
*
* @param ReorderAttachmentsRequest $request 순서 변경 요청
* @return JsonResponse
*/
public function reorder(ReorderAttachmentsRequest $request): JsonResponse
{
try {
$this->attachmentService->reorder($request->input('order'));
return $this->success('attachment.reorder_success');
} catch (Exception $e) {
return $this->error('attachment.reorder_failed', 500, $e->getMessage());
}
}
}
@@ -0,0 +1,105 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Helpers\ResponseHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Auth\LoginRequest;
use App\Http\Resources\UserResource;
use App\Services\AuthService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
class AuthController extends AdminBaseController
{
public function __construct(
private AuthService $authService
) {
parent::__construct();
}
/**
* 관리자를 로그인시킵니다.
*
* @param LoginRequest $request 로그인 요청 데이터
* @return JsonResponse 로그인 결과와 관리자 정보, 토큰을 포함한 JSON 응답
*/
public function login(LoginRequest $request): JsonResponse
{
try {
$data = $this->authService->login(
$request->validated()['email'],
$request->validated()['password']
);
$user = $data['user'];
// 관리자 권한 확인
if (!$user->isAdmin()) {
return ResponseHelper::forbidden('auth.admin_required');
}
// 사용자 정보는 Resource로, 토큰은 그대로
$data['user'] = new UserResource($user);
return $this->success('auth.admin_login_success', $data);
} catch (ValidationException $e) {
return ResponseHelper::unauthorized('auth.login_failed', [], 'core');
}
}
/**
* 관리자를 로그아웃시킵니다.
*
* @param Request $request HTTP 요청
* @return JsonResponse 로그아웃 성공 메시지
*/
public function logout(Request $request): JsonResponse
{
$this->authService->logout($request->user());
return $this->success('auth.logout_success');
}
/**
* 현재 로그인된 관리자의 정보를 반환합니다.
*
* @param Request $request HTTP 요청
* @return JsonResponse 관리자 정보를 포함한 JSON 응답
*/
public function user(Request $request): JsonResponse
{
$user = $request->user();
// 역할 관계 로드 (권한은 역할을 통해 간접 연결)
$user->load(['roles.permissions']);
return $this->successWithResource(
'common.success',
new UserResource($user)
);
}
/**
* 관리자의 인증 토큰을 갱신합니다.
*
* @param Request $request HTTP 요청
* @return JsonResponse 새로운 토큰과 관리자 정보를 포함한 JSON 응답
*/
public function refresh(Request $request): JsonResponse
{
try {
$data = $this->authService->refreshToken($request->user());
// 사용자 정보는 Resource로, 토큰은 그대로
if (isset($data['user'])) {
$data['user'] = new UserResource($data['user']);
}
return $this->success('common.success', $data);
} catch (ValidationException $e) {
return ResponseHelper::unauthorized('auth.unauthenticated', [], 'core');
}
}
}
@@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Extension\ChangelogRequest;
use App\Services\CoreUpdateService;
use Illuminate\Http\JsonResponse;
class CoreUpdateController extends AdminBaseController
{
/**
* CoreUpdateController 생성자.
*
* @param CoreUpdateService $coreUpdateService 코어 업데이트 서비스
*/
public function __construct(private CoreUpdateService $coreUpdateService)
{
parent::__construct();
}
/**
* 코어 업데이트 가능 여부를 확인합니다.
*
* @return JsonResponse
*/
public function checkForUpdates(): JsonResponse
{
$result = $this->coreUpdateService->checkForUpdates();
if (! empty($result['check_failed'])) {
return $this->error(
'settings.core_update.check_failed',
422,
[
'reason' => $result['error'] ?? '',
'current_version' => $result['current_version'],
'github_url' => $result['github_url'],
]
);
}
return $this->success(
'settings.core_update.check_success',
$result
);
}
/**
* 코어 변경사항을 조회합니다.
*
* @param ChangelogRequest $request 검증된 요청
* @return JsonResponse
*/
public function changelog(ChangelogRequest $request): JsonResponse
{
$changelog = $this->coreUpdateService->getChangelog(
$request->validated('from_version'),
$request->validated('to_version')
);
return $this->success(
'settings.fetch_success',
['changelog' => $changelog]
);
}
}
@@ -0,0 +1,93 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Services\DashboardService;
use Illuminate\Http\JsonResponse;
/**
* 관리자 대시보드 컨트롤러
*
* 대시보드에 표시되는 통계, 시스템 리소스, 최근 활동, 알림 등의 데이터를 제공합니다.
*/
class DashboardController extends AdminBaseController
{
public function __construct(
private DashboardService $dashboardService
) {
parent::__construct();
}
/**
* 대시보드 통계를 조회합니다.
*
* 총 사용자 수, 설치된 모듈 수, 활성 플러그인 수, 시스템 상태를 반환합니다.
*
* @return JsonResponse 통계 데이터를 포함한 JSON 응답
*/
public function stats(): JsonResponse
{
try {
$stats = $this->dashboardService->getStats();
return $this->success('dashboard.stats_loaded', $stats);
} catch (\Exception $e) {
return $this->error('dashboard.stats_failed', 500, $e->getMessage());
}
}
/**
* 시스템 리소스 사용량을 조회합니다.
*
* CPU, 메모리, 디스크 사용량 정보를 반환합니다.
*
* @return JsonResponse 리소스 사용량을 포함한 JSON 응답
*/
public function resources(): JsonResponse
{
try {
$resources = $this->dashboardService->getSystemResources();
return $this->success('dashboard.resources_loaded', $resources);
} catch (\Exception $e) {
return $this->error('dashboard.resources_failed', 500, $e->getMessage());
}
}
/**
* 최근 활동 내역을 조회합니다.
*
* 최근 사용자 등록, 모듈 활성화 등의 활동 내역을 반환합니다.
*
* @return JsonResponse 활동 내역을 포함한 JSON 응답
*/
public function activities(): JsonResponse
{
try {
$activities = $this->dashboardService->getRecentActivities();
return $this->success('dashboard.activities_loaded', $activities);
} catch (\Exception $e) {
return $this->error('dashboard.activities_failed', 500, $e->getMessage());
}
}
/**
* 시스템 알림을 조회합니다.
*
* 시스템 업데이트, 경고 등의 알림을 반환합니다.
*
* @return JsonResponse 알림 목록을 포함한 JSON 응답
*/
public function alerts(): JsonResponse
{
try {
$alerts = $this->dashboardService->getSystemAlerts();
return $this->success('dashboard.alerts_loaded', $alerts);
} catch (\Exception $e) {
return $this->error('dashboard.alerts_failed', 500, $e->getMessage());
}
}
}
@@ -0,0 +1,240 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Layout\StoreLayoutPreviewRequest;
use App\Http\Requests\Layout\UpdateLayoutContentRequest;
use App\Http\Resources\LayoutResource;
use App\Http\Resources\LayoutVersionResource;
use App\Services\LayoutPreviewService;
use App\Services\LayoutService;
use App\Services\TemplateService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\DB;
class LayoutController extends AdminBaseController
{
public function __construct(
private LayoutService $layoutService,
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');
}
$layouts = $this->layoutService->getLayoutsByTemplateId($template->id);
return $this->success(
'common.success',
LayoutResource::collection($layouts)
);
}
/**
* 특정 레이아웃 상세 조회
*
* @param string $templateName 템플릿 identifier
* @param string $name 레이아웃 이름
* @return JsonResponse
*/
public function show(string $templateName, string $name): JsonResponse
{
$template = $this->templateService->findByIdentifier($templateName);
if (! $template) {
return $this->notFound('common.not_found');
}
$layout = $this->layoutService->getLayoutByName($template->id, $name);
if (! $layout) {
return $this->notFound('common.not_found');
}
return $this->success(
'common.success',
new LayoutResource($layout)
);
}
/**
* 레이아웃 수정
*
* @param UpdateLayoutContentRequest $request
* @param string $templateName 템플릿 identifier
* @param string $name 레이아웃 이름
* @return JsonResponse
*/
public function update(UpdateLayoutContentRequest $request, string $templateName, string $name): JsonResponse
{
$template = $this->templateService->findByIdentifier($templateName);
if (! $template) {
return $this->notFound('common.not_found');
}
try {
DB::beginTransaction();
$layout = $this->layoutService->updateLayout($template->id, $name, $request->validated());
DB::commit();
return $this->success(
'common.success',
new LayoutResource($layout)
);
} catch (\Exception $e) {
DB::rollBack();
return $this->error(
'common.failed',
500,
['error' => $e->getMessage()]
);
}
}
/**
* 레이아웃의 모든 버전 목록 조회
*
* @param string $templateName 템플릿 identifier
* @param string $name 레이아웃 이름
* @return JsonResponse
*/
public function versions(string $templateName, string $name): JsonResponse
{
$template = $this->templateService->findByIdentifier($templateName);
if (! $template) {
return $this->notFound('common.not_found');
}
$layout = $this->layoutService->getLayoutByName($template->id, $name);
if (! $layout) {
return $this->notFound('common.not_found');
}
$versions = $this->layoutService->getLayoutVersions($template->id, $name);
return $this->success(
'common.success',
LayoutVersionResource::collection($versions)
);
}
/**
* 특정 버전의 레이아웃 content 조회
*
* @param string $templateName 템플릿 identifier
* @param string $name 레이아웃 이름
* @param int $version 버전 번호
* @return JsonResponse
*/
public function showVersion(string $templateName, string $name, int $version): JsonResponse
{
$template = $this->templateService->findByIdentifier($templateName);
if (! $template) {
return $this->notFound('common.not_found');
}
$layoutVersion = $this->layoutService->getLayoutVersion($template->id, $name, $version);
if (! $layoutVersion) {
return $this->notFound('common.not_found');
}
return $this->success(
'common.success',
new LayoutVersionResource($layoutVersion)
);
}
/**
* 버전 복원
*
* @param string $templateName 템플릿 identifier
* @param string $name 레이아웃 이름
* @param int $versionId 버전 ID
* @return JsonResponse
*/
public function restoreVersion(string $templateName, string $name, int $versionId): JsonResponse
{
$template = $this->templateService->findByIdentifier($templateName);
if (! $template) {
return $this->notFound('common.not_found');
}
$newVersion = $this->layoutService->restoreVersion($template->id, $name, $versionId);
if (! $newVersion) {
return $this->notFound('common.not_found');
}
return $this->success(
'common.success',
new LayoutVersionResource($newVersion)
);
}
/**
* 레이아웃 미리보기 생성
*
* 편집 중인 레이아웃 content를 임시 저장하고 미리보기 URL을 반환합니다.
*
* @param StoreLayoutPreviewRequest $request
* @param string $templateName 템플릿 identifier
* @param string $name 레이아웃 이름
* @return JsonResponse
*/
public function storePreview(StoreLayoutPreviewRequest $request, string $templateName, string $name): JsonResponse
{
$template = $this->templateService->findByIdentifier($templateName);
if (! $template) {
return $this->notFound('common.not_found');
}
try {
$preview = $this->layoutPreviewService->createPreview(
$template->id,
$name,
$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()]
);
}
}
}
@@ -0,0 +1,57 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Services\LicenseService;
use Illuminate\Http\JsonResponse;
/**
* 라이선스 컨트롤러
*
* 코어 및 확장의 라이선스 정보를 제공합니다.
*/
class LicenseController extends AdminBaseController
{
public function __construct(
private readonly LicenseService $licenseService
) {
parent::__construct();
}
/**
* 코어 라이선스 파일 내용을 반환합니다.
*
* @return JsonResponse
*/
public function core(): JsonResponse
{
$content = $this->licenseService->getCoreLicense();
if ($content === null) {
return $this->error('common.not_found', 404);
}
return $this->success('common.success', [
'content' => $content,
]);
}
/**
* 코어 CHANGELOG.md 파일 내용을 반환합니다.
*
* @return JsonResponse
*/
public function changelog(): JsonResponse
{
$content = $this->licenseService->getCoreChangelog();
if ($content === null) {
return $this->error('common.not_found', 404);
}
return $this->success('common.success', [
'content' => $content,
]);
}
}
@@ -0,0 +1,112 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Helpers\PermissionHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\MailSendLog\MailSendLogBulkDeleteRequest;
use App\Http\Requests\MailSendLog\MailSendLogDeleteRequest;
use App\Http\Requests\MailSendLog\MailSendLogIndexRequest;
use App\Http\Resources\MailSendLogCollection;
use App\Models\MailSendLog;
use App\Services\MailSendLogService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
/**
* 메일 발송 이력 관리 컨트롤러
*/
class MailSendLogController extends AdminBaseController
{
/**
* MailSendLogController 생성자.
*
* @param MailSendLogService $mailSendLogService 메일 발송 이력 서비스
*/
public function __construct(
private MailSendLogService $mailSendLogService
) {
parent::__construct();
}
/**
* 메일 발송 이력 목록을 조회합니다.
*
* @param MailSendLogIndexRequest $request 검증된 조회 요청
* @return JsonResponse 발송 이력 목록
*/
public function index(MailSendLogIndexRequest $request): JsonResponse
{
try {
$filters = array_filter([
'extension_type' => $request->validated('extension_type'),
'extension_identifier' => $request->validated('extension_identifier'),
'template_type' => $request->validated('template_type'),
'status' => $request->validated('status'),
'search' => $request->validated('search'),
'search_type' => $request->validated('search_type'),
'date_from' => $request->validated('date_from'),
'date_to' => $request->validated('date_to'),
'sort_by' => $request->validated('sort_by'),
'sort_order' => $request->validated('sort_order'),
], fn ($value) => $value !== null);
$perPage = (int) ($request->validated('per_page') ?? 20);
$logs = $this->mailSendLogService->getLogs($filters, $perPage);
$collection = new MailSendLogCollection($logs);
$responseData = $collection->toArray($request);
// 컬렉션 레벨 abilities (페이지 레벨 버튼 제어용)
$responseData['abilities'] = [
'can_delete' => PermissionHelper::check('core.mail-send-logs.delete', $request->user()),
];
return $this->success('mail_send_log.fetch_success', $responseData);
} catch (\Exception $e) {
Log::error('메일 발송 이력 조회 실패', ['error' => $e->getMessage()]);
return $this->error('mail_send_log.fetch_failed', 500);
}
}
/**
* 메일 발송 이력을 삭제합니다.
*
* @param MailSendLogDeleteRequest $request 검증된 삭제 요청
* @param MailSendLog $mailSendLog 삭제할 발송 이력 모델
* @return JsonResponse 삭제 결과
*/
public function destroy(MailSendLogDeleteRequest $request, MailSendLog $mailSendLog): JsonResponse
{
try {
$this->mailSendLogService->delete($mailSendLog->id);
return $this->success('mail_send_log.delete_success');
} catch (\Exception $e) {
Log::error('메일 발송 이력 삭제 실패', ['id' => $mailSendLog->id, 'error' => $e->getMessage()]);
return $this->error('mail_send_log.delete_failed', 500);
}
}
/**
* 메일 발송 이력을 일괄 삭제합니다.
*
* @param MailSendLogBulkDeleteRequest $request 검증된 일괄 삭제 요청
* @return JsonResponse 삭제 결과
*/
public function bulkDestroy(MailSendLogBulkDeleteRequest $request): JsonResponse
{
try {
$count = $this->mailSendLogService->deleteMany($request->validated('ids'));
return $this->success('mail_send_log.bulk_delete_success', ['deleted_count' => $count]);
} catch (\Exception $e) {
Log::error('메일 발송 이력 일괄 삭제 실패', ['error' => $e->getMessage()]);
return $this->error('mail_send_log.delete_failed', 500);
}
}
}
@@ -0,0 +1,153 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\MailTemplate\MailTemplateIndexRequest;
use App\Http\Requests\MailTemplate\MailTemplatePreviewRequest;
use App\Http\Requests\MailTemplate\UpdateMailTemplateRequest;
use App\Http\Resources\MailTemplateCollection;
use App\Http\Resources\MailTemplateResource;
use App\Models\MailTemplate;
use App\Services\MailTemplateService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
/**
* 메일 템플릿 관리 컨트롤러
*
* 메일 템플릿의 조회, 수정, 미리보기, 초기화 기능을 제공합니다.
*/
class MailTemplateController extends AdminBaseController
{
/**
* MailTemplateController 생성자.
*
* @param MailTemplateService $mailTemplateService 메일 템플릿 서비스
*/
public function __construct(
private MailTemplateService $mailTemplateService
) {
parent::__construct();
}
/**
* 메일 템플릿 목록을 페이지네이션하여 조회합니다.
*
* @param MailTemplateIndexRequest $request 검증된 목록 조회 요청
* @return JsonResponse 페이지네이션된 템플릿 목록
*/
public function index(MailTemplateIndexRequest $request): JsonResponse
{
try {
$filters = array_filter($request->validated(), fn ($value) => $value !== null);
$perPage = (int) ($request->validated('per_page') ?? 20);
$templates = $this->mailTemplateService->getTemplates($filters, $perPage);
$collection = new MailTemplateCollection($templates);
return $this->success('mail_template.fetch_success', $collection->toArray($request));
} catch (\Exception $e) {
Log::error('메일 템플릿 목록 조회 실패', ['error' => $e->getMessage()]);
return $this->error('mail_template.fetch_failed', 500);
}
}
/**
* 메일 템플릿을 수정합니다.
*
* @param UpdateMailTemplateRequest $request 수정 요청
* @param MailTemplate $mailTemplate 수정 대상
* @return JsonResponse 수정 결과
*/
public function update(UpdateMailTemplateRequest $request, MailTemplate $mailTemplate): JsonResponse
{
try {
$userId = $request->user()?->id;
$template = $this->mailTemplateService->updateTemplate(
$mailTemplate,
$request->validated(),
$userId
);
return $this->success(
'mail_template.save_success',
new MailTemplateResource($template)
);
} catch (\Exception $e) {
Log::error('메일 템플릿 수정 실패', ['error' => $e->getMessage()]);
return $this->error('mail_template.save_error', 500);
}
}
/**
* 메일 템플릿의 활성 상태를 토글합니다.
*
* @param MailTemplate $mailTemplate 토글 대상
* @return JsonResponse 토글 결과
*/
public function toggleActive(MailTemplate $mailTemplate): JsonResponse
{
try {
$template = $this->mailTemplateService->toggleActive($mailTemplate);
return $this->success(
'mail_template.toggle_success',
new MailTemplateResource($template)
);
} catch (\Exception $e) {
Log::error('메일 템플릿 활성 상태 토글 실패', ['error' => $e->getMessage()]);
return $this->error('mail_template.toggle_failed', 500);
}
}
/**
* 메일 템플릿 미리보기를 반환합니다.
*
* @param MailTemplatePreviewRequest $request 검증된 미리보기 요청
* @return JsonResponse 미리보기 결과
*/
public function preview(MailTemplatePreviewRequest $request): JsonResponse
{
try {
$result = $this->mailTemplateService->getPreview($request->validated());
return $this->success('mail_template.preview_success', $result);
} catch (\Exception $e) {
Log::error('메일 템플릿 미리보기 실패', ['error' => $e->getMessage()]);
return $this->error('mail_template.preview_failed', 500);
}
}
/**
* 메일 템플릿을 시더 기본값으로 복원합니다.
*
* @param MailTemplate $mailTemplate 복원 대상
* @return JsonResponse 복원 결과
*/
public function reset(MailTemplate $mailTemplate): JsonResponse
{
try {
$defaultData = $this->mailTemplateService->getDefaultTemplateData($mailTemplate->type);
if (! $defaultData) {
return $this->error('mail_template.reset_no_default', 404);
}
$template = $this->mailTemplateService->resetToDefault($mailTemplate, $defaultData);
return $this->success(
'mail_template.reset_success',
new MailTemplateResource($template)
);
} catch (\Exception $e) {
Log::error('메일 템플릿 기본값 복원 실패', ['error' => $e->getMessage()]);
return $this->error('mail_template.reset_failed', 500);
}
}
}
@@ -0,0 +1,276 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Enums\ExtensionOwnerType;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Menu\CreateMenuRequest;
use App\Http\Requests\Menu\MenuListRequest;
use App\Http\Requests\Menu\UpdateMenuOrderRequest;
use App\Http\Requests\Menu\UpdateMenuRequest;
use App\Http\Resources\MenuCollection;
use App\Http\Resources\MenuResource;
use App\Models\Menu;
use App\Services\MenuService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
/**
* 관리자용 메뉴 컨트롤러
*
* 관리자가 시스템 메뉴를 생성, 수정, 삭제, 순서 변경할 수 있는 기능을 제공합니다.
*/
class MenuController extends AdminBaseController
{
public function __construct(
private MenuService $menuService
) {
parent::__construct();
}
public function index(MenuListRequest $request): JsonResponse
{
try {
$filters = $request->validated();
$user = Auth::user();
// 필터가 있으면 필터링된 메뉴 조회, 없으면 전체 조회
if (isset($filters['is_active']) || ! empty($filters['filters'])) {
$menus = $this->menuService->getFilteredMenusForManagement($filters, $user);
} else {
$menus = $this->menuService->getTopLevelMenusForManagement($user);
}
return $this->successWithResource(
'menu.fetch_success',
new MenuCollection($menus)
);
} catch (\Exception $e) {
return $this->error('menu.fetch_failed', 500, $e->getMessage());
}
}
/**
* 최상위 메뉴들을 계층 구조로 반환합니다.
*
* @return JsonResponse 계층 구조로 정리된 메뉴 데이터를 포함한 JSON 응답
*/
public function hierarchy(): JsonResponse
{
try {
$menuHierarchy = $this->menuService->getMenuHierarchy();
$collection = new MenuCollection($menuHierarchy);
// 계층 구조로 변환
$hierarchicalData = $collection->toNavigationArray();
return $this->success('menu.fetch_success', $hierarchicalData);
} catch (\Exception $e) {
return $this->error('menu.fetch_failed', 500, $e->getMessage());
}
}
/**
* 활성화된 메뉴들을 네비게이션용으로 반환합니다.
*
* @return JsonResponse 네비게이션용 메뉴 데이터를 포함한 JSON 응답
*/
public function active(): JsonResponse
{
try {
// 현재 사용자 정보 가져오기
$user = Auth::user();
if ($user) {
// 사용자가 접근 가능한 메뉴만 조회
$navigationMenus = $this->menuService->getAccessibleNavigationMenus($user);
} else {
// 사용자 정보가 없으면 모든 활성화된 메뉴 조회 (fallback)
$navigationMenus = $this->menuService->getNavigationMenus();
}
$collection = new MenuCollection($navigationMenus);
// 네비게이션용 계층 구조로 변환
$navigationData = $collection->toNavigationArray();
return $this->success('menu.fetch_success', $navigationData['data']);
} catch (\Exception $e) {
return $this->error('menu.active_fetch_failed', 500, $e->getMessage());
}
}
/**
* 새로운 메뉴를 생성합니다.
*
* @param CreateMenuRequest $request 메뉴 생성 요청 데이터
* @return JsonResponse 생성된 메뉴 정보를 포함한 JSON 응답
*/
public function store(CreateMenuRequest $request): JsonResponse
{
try {
$menu = $this->menuService->createMenu($request->validated());
$menu->load(['creator', 'parent', 'children', 'roles']);
return $this->successWithResource(
'menu.create_success',
new MenuResource($menu),
201
);
} catch (ValidationException $e) {
return $this->error('menu.create_failed', 422, $e->errors());
} catch (\Exception $e) {
return $this->error('menu.create_error', 500, $e->getMessage());
}
}
/**
* 특정 메뉴의 상세 정보를 조회합니다.
*
* @param Menu $menu 조회할 메뉴 모델
* @return JsonResponse 메뉴 상세 정보를 포함한 JSON 응답
*/
public function show(Menu $menu): JsonResponse
{
try {
$menu->load(['creator', 'parent', 'children', 'roles']);
return $this->successWithResource(
'menu.fetch_success',
new MenuResource($menu)
);
} catch (\Exception $e) {
return $this->error('menu.fetch_failed', 500, $e->getMessage());
}
}
/**
* 기존 메뉴 정보를 업데이트합니다.
*
* @param UpdateMenuRequest $request 메뉴 업데이트 요청 데이터
* @param Menu $menu 업데이트할 메뉴 모델
* @return JsonResponse 업데이트된 메뉴 정보를 포함한 JSON 응답
*/
public function update(UpdateMenuRequest $request, Menu $menu): JsonResponse
{
try {
$result = $this->menuService->updateMenu($menu, $request->validated());
if ($result) {
$menu->refresh();
$menu->load(['creator', 'parent', 'children', 'roles']);
return $this->successWithResource(
'menu.update_success',
new MenuResource($menu)
);
} else {
return $this->error('menu.update_failed');
}
} catch (ValidationException $e) {
return $this->error('menu.update_failed', 422, $e->errors());
} catch (\Exception $e) {
return $this->error('menu.update_error', 500, $e->getMessage());
}
}
/**
* 메뉴를 삭제합니다.
*
* @param Menu $menu 삭제할 메뉴 모델
* @return JsonResponse 삭제 결과 JSON 응답
*/
public function destroy(Menu $menu): JsonResponse
{
try {
$menuId = $menu->id;
$result = $this->menuService->deleteMenu($menu);
if ($result) {
return $this->success('menu.delete_success');
} else {
return $this->error('menu.delete_failed');
}
} catch (ValidationException $e) {
return $this->error('menu.delete_failed', 422, $e->errors());
} catch (\Exception $e) {
return $this->error('menu.delete_error', 500, $e->getMessage());
}
}
/**
* 메뉴의 순서를 업데이트합니다 (드래그 앤 드롭).
*
* @param UpdateMenuOrderRequest $request 메뉴 순서 업데이트 요청 데이터
* @return JsonResponse 순서 업데이트 결과 JSON 응답
*/
public function updateOrder(UpdateMenuOrderRequest $request): JsonResponse
{
try {
$validatedData = $request->validated();
$result = $this->menuService->updateMenuOrderWithHierarchy($validatedData);
if ($result) {
return $this->success('menu.order_update_success');
} else {
return $this->error('menu.order_update_failed');
}
} catch (ValidationException $e) {
return $this->error('menu.order_update_failed', 422, $e->errors());
} catch (\Exception $e) {
return $this->error('menu.update_error', 500, $e->getMessage());
}
}
/**
* 메뉴의 활성화 상태를 토글합니다.
*
* @param Menu $menu 대상 메뉴 모델
* @return JsonResponse 상태 변경된 메뉴 정보를 포함한 JSON 응답
*/
public function toggleStatus(Menu $menu): JsonResponse
{
try {
$result = $this->menuService->toggleMenuStatus($menu);
if ($result) {
$menu->refresh();
return $this->successWithResource(
'menu.update_success',
new MenuResource($menu)
);
} else {
return $this->error('menu.update_failed');
}
} catch (\Exception $e) {
return $this->error('menu.update_error', 500, $e->getMessage());
}
}
/**
* 특정 확장에 속한 메뉴들을 조회합니다.
*
* @param string $type 확장 타입 (module, plugin)
* @param string $identifier 확장 식별자
* @return JsonResponse 확장에 속한 메뉴 목록을 포함한 JSON 응답
*/
public function getByExtension(string $type, string $identifier): JsonResponse
{
try {
$extensionType = ExtensionOwnerType::from($type);
$menus = $this->menuService->getMenusByExtension($extensionType, $identifier);
return $this->successWithResource(
'menu.fetch_success',
new MenuCollection($menus)
);
} catch (\ValueError $e) {
return $this->error('menu.invalid_extension_type', 422, $e->getMessage());
} catch (\Exception $e) {
return $this->error('menu.fetch_failed', 500, $e->getMessage());
}
}
}
@@ -0,0 +1,523 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Helpers\PermissionHelper;
use App\Helpers\ResponseHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Module\ActivateModuleRequest;
use App\Http\Requests\Module\DeactivateModuleRequest;
use App\Http\Requests\Module\IndexModuleRequest;
use App\Http\Requests\Module\InstallModuleFromFileRequest;
use App\Http\Requests\Module\InstallModuleFromGithubRequest;
use App\Http\Requests\Module\InstallModuleRequest;
use App\Http\Requests\Module\RefreshModuleLayoutsRequest;
use App\Http\Requests\Module\UninstallModuleRequest;
use App\Http\Requests\Extension\ChangelogRequest;
use App\Http\Resources\ModuleCollection;
use App\Http\Resources\ModuleResource;
use App\Services\LicenseService;
use App\Services\ModuleService;
use App\Services\TemplateService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
/**
* 관리자용 모듈 관리 컨트롤러
*
* 관리자가 시스템 모듈을 설치, 활성화, 비활성화, 제거할 수 있는 기능을 제공합니다.
*/
class ModuleController extends AdminBaseController
{
public function __construct(
private ModuleService $moduleService,
private TemplateService $templateService,
private LicenseService $licenseService
) {
parent::__construct();
}
/**
* 모든 모듈 목록을 조회합니다 (설치된 모듈과 미설치 모듈 포함).
*
* 페이지네이션 및 다중 검색 조건을 지원합니다.
* - search: 단일 검색어 (이름, 식별자, 설명, 벤더 OR 검색)
* - filters: 다중 검색 조건 (AND 조건)
* - with[]: 추가 데이터 포함 (예: custom_menus)
*
* @param IndexModuleRequest $request 모듈 목록 조회 요청
* @return JsonResponse 모듈 목록을 포함한 JSON 응답
*/
public function index(IndexModuleRequest $request): JsonResponse
{
try {
$responseData = $this->moduleService->getIndexData(
$request->validated(),
$request->hasWithOption('custom_menus')
);
// ModuleCollection 변환 (전체 모듈 목록 반환 시에만)
if (! empty($responseData['data'])) {
$collection = new ModuleCollection(collect($responseData['data']));
$responseData['data'] = $collection->toArray($request)['data'];
$responseData['meta'] = $collection->with($request)['meta'];
}
// 컬렉션 레벨 abilities (페이지 레벨 버튼 제어용)
$responseData['abilities'] = [
'can_install' => PermissionHelper::check('core.modules.install', $request->user()),
'can_activate' => PermissionHelper::check('core.modules.activate', $request->user()),
'can_uninstall' => PermissionHelper::check('core.modules.uninstall', $request->user()),
];
return $this->success('module.fetch_success', $responseData);
} catch (\Exception $e) {
return $this->error('module.fetch_failed', 500, $e->getMessage());
}
}
/**
* 설치된 모듈만 조회합니다.
*
* @param Request $request HTTP 요청 객체
* @return JsonResponse 설치된 모듈 목록을 포함한 JSON 응답
*/
public function installed(Request $request): JsonResponse
{
try {
$modules = $this->moduleService->getInstalledModulesOnly();
$collection = new ModuleCollection(collect($modules));
return $this->success('module.fetch_success', [
'data' => $collection->toArray($request)['data'],
'meta' => $collection->with($request)['meta'],
]);
} catch (\Exception $e) {
return $this->error('module.fetch_failed', 500, $e->getMessage());
}
}
/**
* 미설치 모듈만 조회합니다.
*
* @param Request $request HTTP 요청 객체
* @return JsonResponse 미설치 모듈 목록을 포함한 JSON 응답
*/
public function uninstalled(Request $request): JsonResponse
{
try {
$modules = $this->moduleService->getUninstalledModulesOnly();
$collection = new ModuleCollection(collect($modules));
return $this->success('module.fetch_success', [
'data' => $collection->toArray($request)['data'],
'meta' => $collection->with($request)['meta'],
]);
} catch (\Exception $e) {
return $this->error('module.fetch_failed', 500, $e->getMessage());
}
}
/**
* 특정 모듈의 상세 정보를 조회합니다.
*
* @param string $moduleName 모듈명
* @return JsonResponse 모듈 정보를 포함한 JSON 응답
*/
public function show(string $moduleName): JsonResponse
{
try {
$moduleInfo = $this->moduleService->getModuleInfo($moduleName);
if (! $moduleInfo) {
return $this->error('module.not_found', 404, null, ['module' => $moduleName]);
}
// 상세 정보는 toDetailArray() 메서드 사용
$resource = new ModuleResource($moduleInfo);
return $this->success('module.fetch_success', $resource->toDetailArray());
} catch (\Exception $e) {
return $this->error('module.fetch_failed', 500, $e->getMessage());
}
}
/**
* 모듈을 시스템에 설치합니다.
*
* @param InstallModuleRequest $request 모듈 설치 요청 데이터
* @return JsonResponse 설치된 모듈 정보를 포함한 JSON 응답
*/
public function install(InstallModuleRequest $request): JsonResponse
{
try {
$moduleName = $request->validated()['module_name'];
$module = $this->moduleService->installModule($moduleName);
if ($module) {
return $this->successWithResource(
'module.install_success',
new ModuleResource($module),
201
);
} else {
return $this->error('module.install_failed');
}
} catch (ValidationException $e) {
// Service에서 이미 번역된 메시지를 errors에 포함하므로
// 첫 번째 에러를 top-level message로 직접 사용 (이중 래핑 방지)
$firstError = collect($e->errors())->flatten()->first()
?? __('module.install_failed');
return ResponseHelper::error($firstError, 422, $e->errors());
} catch (\Exception $e) {
return $this->error('modules.installation_failed', 500, $e->getMessage(), [
'error' => $e->getMessage(),
]);
}
}
/**
* 모듈을 활성화합니다.
*
* force 파라미터가 없고 필요한 의존성이 충족되지 않은 경우 경고를 반환합니다.
*
* @param ActivateModuleRequest $request 모듈 활성화 요청 데이터
* @return JsonResponse 활성화된 모듈 정보를 포함한 JSON 응답
*/
public function activate(ActivateModuleRequest $request): JsonResponse
{
try {
$validated = $request->validated();
$moduleName = $validated['module_name'];
$force = $validated['force'] ?? false;
$result = $this->moduleService->activateModule($moduleName, $force);
// 경고 응답인 경우 (필요 의존성 미충족) - 활성화 실패로 처리
if (isset($result['warning']) && $result['warning'] === true) {
return $this->error('modules.activate_warning', 409, [
'warning' => true,
'missing_modules' => $result['missing_modules'] ?? [],
'missing_plugins' => $result['missing_plugins'] ?? [],
'message' => $result['message'],
]);
}
if ($result['success']) {
$moduleInfo = $result['module_info'] ?? null;
if ($moduleInfo) {
return $this->successWithResource(
'module.activate_success',
new ModuleResource($moduleInfo)
);
}
return $this->success('module.activate_success', $result);
} else {
return $this->error('module.activate_failed');
}
} catch (ValidationException $e) {
return $this->error('module.activate_failed', 422, $e->errors());
} catch (\Exception $e) {
return $this->error('module.activate_failed', 500, $e->getMessage());
}
}
/**
* 모듈을 비활성화합니다.
*
* force 파라미터가 없고 의존하는 확장이 있는 경우 경고를 반환합니다.
*
* @param DeactivateModuleRequest $request 모듈 비활성화 요청 데이터
* @return JsonResponse 비활성화된 모듈 정보를 포함한 JSON 응답
*/
public function deactivate(DeactivateModuleRequest $request): JsonResponse
{
try {
$validated = $request->validated();
$moduleName = $validated['module_name'];
$force = $validated['force'] ?? false;
$result = $this->moduleService->deactivateModule($moduleName, $force);
// 경고 응답인 경우 (의존 확장 존재) - 비활성화 실패로 처리
if (isset($result['warning']) && $result['warning'] === true) {
return $this->error('modules.deactivate_warning', 409, [
'warning' => true,
'dependent_templates' => $result['dependent_templates'] ?? [],
'dependent_modules' => $result['dependent_modules'] ?? [],
'dependent_plugins' => $result['dependent_plugins'] ?? [],
'message' => $result['message'],
]);
}
if ($result['success']) {
$moduleInfo = $result['module_info'] ?? null;
if ($moduleInfo) {
return $this->successWithResource(
'module.deactivate_success',
new ModuleResource($moduleInfo)
);
}
return $this->success('module.deactivate_success', $result);
} else {
return $this->error('module.deactivate_failed');
}
} catch (ValidationException $e) {
return $this->error('module.deactivate_failed', 422, $e->errors());
} catch (\Exception $e) {
return $this->error('module.deactivate_failed', 500, $e->getMessage());
}
}
/**
* 모듈에 의존하는 템플릿 목록을 조회합니다.
*
* @param string $identifier 모듈 식별자
* @return JsonResponse 의존 템플릿 목록을 포함한 JSON 응답
*/
public function dependentTemplates(string $identifier): JsonResponse
{
try {
$dependentTemplates = $this->templateService->getTemplatesDependingOnModule($identifier);
return $this->success('module.dependent_templates_success', [
'data' => $dependentTemplates,
'total' => count($dependentTemplates),
]);
} catch (\Exception $e) {
return $this->error('module.dependent_templates_failed', 500, $e->getMessage());
}
}
/**
* 모듈 삭제 시 삭제될 데이터 정보를 조회합니다.
*
* @param string $moduleName 모듈명
* @return JsonResponse 삭제 정보를 포함한 JSON 응답
*/
public function uninstallInfo(string $moduleName): JsonResponse
{
try {
$uninstallInfo = $this->moduleService->getModuleUninstallInfo($moduleName);
if (! $uninstallInfo) {
return $this->error('module.not_found', 404, null, ['module' => $moduleName]);
}
return $this->success('module.uninstall_info_success', $uninstallInfo);
} catch (\Exception $e) {
return $this->error('module.uninstall_info_failed', 500, $e->getMessage());
}
}
/**
* 모듈을 시스템에서 제거합니다.
*
* @param UninstallModuleRequest $request 모듈 제거 요청 데이터
* @return JsonResponse 제거 결과 JSON 응답
*/
public function uninstall(UninstallModuleRequest $request): JsonResponse
{
try {
$validated = $request->validated();
$moduleName = $validated['module_name'];
$deleteData = $validated['delete_data'] ?? false;
$result = $this->moduleService->uninstallModule($moduleName, $deleteData);
if ($result) {
return $this->success('module.uninstall_success');
} else {
return $this->error('module.uninstall_failed');
}
} catch (ValidationException $e) {
return $this->error('module.uninstall_failed', 422, $e->errors());
} catch (\Exception $e) {
return $this->error('module.uninstall_failed', 500, $e->getMessage());
}
}
/**
* ZIP 파일에서 모듈을 설치합니다.
*
* @param InstallModuleFromFileRequest $request 파일 설치 요청 데이터
* @return JsonResponse 설치된 모듈 정보를 포함한 JSON 응답
*/
public function installFromFile(InstallModuleFromFileRequest $request): JsonResponse
{
try {
$file = $request->file('file');
$module = $this->moduleService->installFromZipFile($file);
return $this->successWithResource(
'module.install_success',
new ModuleResource($module),
201
);
} catch (\RuntimeException $e) {
return $this->error($e->getMessage(), 422);
} catch (\Exception $e) {
return $this->error('module.install_failed', 500, ['error' => $e->getMessage()]);
}
}
/**
* GitHub 저장소에서 모듈을 설치합니다.
*
* @param InstallModuleFromGithubRequest $request GitHub 설치 요청 데이터
* @return JsonResponse 설치된 모듈 정보를 포함한 JSON 응답
*/
public function installFromGithub(InstallModuleFromGithubRequest $request): JsonResponse
{
try {
$githubUrl = $request->validated()['github_url'];
$module = $this->moduleService->installFromGithub($githubUrl);
return $this->successWithResource(
'module.install_success',
new ModuleResource($module),
201
);
} catch (\RuntimeException $e) {
return $this->error($e->getMessage(), 422);
} catch (\Exception $e) {
return $this->error('module.install_failed', 500, ['error' => $e->getMessage()]);
}
}
/**
* 설치된 모든 모듈의 업데이트를 확인합니다.
*
* @return JsonResponse 업데이트 확인 결과 JSON 응답
*/
public function checkUpdates(): JsonResponse
{
try {
$result = $this->moduleService->checkForUpdates();
return $this->success('modules.check_updates_success', $result);
} catch (ValidationException $e) {
return $this->error('modules.check_updates_failed', 422, $e->errors());
} catch (\Exception $e) {
return $this->error('modules.check_updates_failed', 500, $e->getMessage());
}
}
/**
* 특정 모듈을 업데이트합니다.
*
* @param string $moduleName 업데이트할 모듈 identifier
* @return JsonResponse 업데이트 결과 JSON 응답
*/
public function performUpdate(string $moduleName): JsonResponse
{
try {
$result = $this->moduleService->updateModule($moduleName);
$moduleInfo = $result['module_info'] ?? null;
if ($moduleInfo) {
return $this->successWithResource(
'modules.update_success',
new ModuleResource($moduleInfo)
);
}
return $this->success('modules.update_success', $result);
} catch (ValidationException $e) {
// Service/Manager에서 이미 번역된 메시지를 errors에 포함하므로
// 첫 번째 에러를 top-level message로 직접 사용 (이중 래핑 방지)
$firstError = collect($e->errors())->flatten()->first()
?? __('modules.errors.update_failed', ['module' => $moduleName, 'error' => '']);
return ResponseHelper::error($firstError, 422, $e->errors());
} catch (\Exception $e) {
return $this->error('modules.errors.update_failed', 500, $e->getMessage(), [
'module' => $moduleName,
'error' => $e->getMessage(),
]);
}
}
/**
* 모듈의 레이아웃을 파일에서 다시 읽어 갱신합니다.
*
* @param RefreshModuleLayoutsRequest $request 레이아웃 갱신 요청 데이터
* @return JsonResponse 갱신된 모듈 정보를 포함한 JSON 응답
*/
public function refreshLayouts(RefreshModuleLayoutsRequest $request): JsonResponse
{
try {
$moduleName = $request->validated()['module_name'];
$module = $this->moduleService->refreshModuleLayouts($moduleName);
if ($module) {
return $this->successWithResource(
'module.refresh_layouts_success',
new ModuleResource($module)
);
} else {
return $this->error('module.refresh_layouts_failed');
}
} catch (ValidationException $e) {
return $this->error('module.refresh_layouts_failed', 422, $e->errors());
} catch (\Exception $e) {
return $this->error('module.refresh_layouts_failed', 500, $e->getMessage());
}
}
/**
* 특정 모듈의 변경 내역(changelog)을 조회합니다.
*
* @param ChangelogRequest $request 검증된 요청
* @param string $identifier 모듈 식별자
* @return JsonResponse 변경 내역을 포함한 JSON 응답
*/
public function changelog(ChangelogRequest $request, string $identifier): JsonResponse
{
try {
$validated = $request->validated();
$changelog = $this->moduleService->getModuleChangelog(
$identifier,
$validated['source'] ?? null,
$validated['from_version'] ?? null,
$validated['to_version'] ?? null,
);
return $this->success('module.fetch_success', ['changelog' => $changelog]);
} catch (\Exception $e) {
return $this->error('module.fetch_failed', 500, $e->getMessage());
}
}
/**
* 모듈의 라이선스 파일 내용을 반환합니다.
*
* @param string $identifier 모듈 식별자
* @return JsonResponse
*/
public function license(string $identifier): JsonResponse
{
if (! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $identifier)) {
return $this->error('module.license_not_found', 404);
}
$content = $this->licenseService->getExtensionLicense('modules', $identifier);
if ($content === null) {
return $this->error('module.license_not_found', 404);
}
return $this->success('module.fetch_success', [
'content' => $content,
]);
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Api\Base\AdminBaseController;
use Illuminate\Http\JsonResponse;
/**
* 관리자 알림 컨트롤러 (더미)
*
* 추후 알림 기능 구현 예정. 현재는 빈 목록을 반환합니다.
*/
class NotificationController extends AdminBaseController
{
/**
* 알림 목록을 조회합니다.
*
* @return JsonResponse 알림 목록 (현재 빈 배열)
*/
public function index(): JsonResponse
{
// TODO: 실제 알림 기능 구현 시 교체 예정
return $this->success('messages.success', [
'data' => [],
'meta' => [
'total' => 0,
'unread_count' => 0,
],
]);
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Services\PermissionService;
use Exception;
use Illuminate\Http\JsonResponse;
/**
* 관리자용 권한 관리 컨트롤러
*
* 권한 목록을 계층형 트리 구조로 제공합니다.
*/
class PermissionController extends AdminBaseController
{
public function __construct(
private PermissionService $permissionService
) {
parent::__construct();
}
/**
* 계층형 권한 트리를 조회합니다.
*
* @return JsonResponse 계층형 권한 트리를 포함한 JSON 응답
*/
public function index(): JsonResponse
{
try {
$permissionTree = $this->permissionService->getPermissionTree();
return $this->success('permission.fetch_success', [
'data' => $permissionTree,
]);
} catch (Exception $e) {
return $this->error('permission.fetch_failed', 500, $e->getMessage());
}
}
}
@@ -0,0 +1,545 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Helpers\PermissionHelper;
use App\Helpers\ResponseHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Plugin\ActivatePluginRequest;
use App\Http\Requests\Plugin\DeactivatePluginRequest;
use App\Http\Requests\Plugin\IndexPluginRequest;
use App\Http\Requests\Plugin\InstallPluginFromFileRequest;
use App\Http\Requests\Plugin\InstallPluginFromGithubRequest;
use App\Http\Requests\Plugin\InstallPluginRequest;
use App\Http\Requests\Plugin\RefreshPluginLayoutsRequest;
use App\Http\Requests\Plugin\UninstallPluginRequest;
use App\Http\Requests\Extension\ChangelogRequest;
use App\Http\Resources\PluginCollection;
use App\Http\Resources\PluginResource;
use App\Services\LicenseService;
use App\Services\PluginService;
use App\Services\TemplateService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
/**
* 관리자용 플러그인 관리 컨트롤러
*
* 관리자가 시스템 플러그인을 설치, 활성화, 비활성화, 제거할 수 있는 기능을 제공합니다.
*/
class PluginController extends AdminBaseController
{
public function __construct(
private PluginService $pluginService,
private TemplateService $templateService,
private LicenseService $licenseService
) {
parent::__construct();
}
/**
* 모든 플러그인 목록을 조회합니다 (설치된 플러그인과 미설치 플러그인 포함).
*
* 페이지네이션 및 다중 검색 조건을 지원합니다.
* - search: 단일 검색어 (이름, 식별자, 설명, 벤더 OR 검색)
* - filters: 다중 검색 조건 (AND 조건)
*
* @param IndexPluginRequest $request 플러그인 목록 조회 요청
* @return JsonResponse 플러그인 목록을 포함한 JSON 응답
*/
public function index(IndexPluginRequest $request): JsonResponse
{
try {
$validated = $request->validated();
$filters = [
'search' => $validated['search'] ?? null,
'filters' => $validated['filters'] ?? [],
'status' => $validated['status'] ?? null,
];
$perPage = (int) ($validated['per_page'] ?? 12);
$page = (int) ($validated['page'] ?? 1);
$result = $this->pluginService->getPaginatedPlugins($filters, $perPage, $page);
$collection = new PluginCollection(collect($result['data']));
return $this->success('plugins.list_success', [
'data' => $collection->toArray($request)['data'],
'pagination' => [
'total' => $result['total'],
'current_page' => $result['current_page'],
'last_page' => $result['last_page'],
'per_page' => $result['per_page'],
],
'meta' => $collection->with($request)['meta'],
'abilities' => [
'can_install' => PermissionHelper::check('core.plugins.install', $request->user()),
'can_activate' => PermissionHelper::check('core.plugins.activate', $request->user()),
'can_uninstall' => PermissionHelper::check('core.plugins.uninstall', $request->user()),
],
]);
} catch (\Exception $e) {
return $this->error('plugins.list_failed', 500, $e->getMessage());
}
}
/**
* 설치된 플러그인만 조회합니다.
*
* @param Request $request HTTP 요청 객체
* @return JsonResponse 설치된 플러그인 목록을 포함한 JSON 응답
*/
public function installed(Request $request): JsonResponse
{
try {
$plugins = $this->pluginService->getInstalledPluginsOnly();
$collection = new PluginCollection(collect($plugins));
return $this->success('plugins.list_success', [
'data' => $collection->toArray($request)['data'],
'meta' => $collection->with($request)['meta'],
]);
} catch (\Exception $e) {
return $this->error('plugins.list_failed', 500, $e->getMessage());
}
}
/**
* 특정 플러그인의 상세 정보를 조회합니다.
*
* @param string $pluginName 플러그인명
* @return JsonResponse 플러그인 정보를 포함한 JSON 응답
*/
public function show(string $pluginName): JsonResponse
{
try {
$pluginInfo = $this->pluginService->getPluginInfo($pluginName);
if (! $pluginInfo) {
return $this->error('plugins.not_found', 404, null, ['plugin' => $pluginName]);
}
// 상세 정보는 toDetailArray() 메서드 사용
$resource = new PluginResource($pluginInfo);
return $this->success('plugins.fetch_success', $resource->toDetailArray());
} catch (\Exception $e) {
return $this->error('plugins.fetch_failed', 500, $e->getMessage());
}
}
/**
* 플러그인을 시스템에 설치합니다.
*
* @param InstallPluginRequest $request 플러그인 설치 요청 데이터
* @return JsonResponse 설치된 플러그인 정보를 포함한 JSON 응답
*/
public function install(InstallPluginRequest $request): JsonResponse
{
try {
$pluginName = $request->validated()['plugin_name'];
$pluginInfo = $this->pluginService->installPlugin($pluginName);
if ($pluginInfo) {
return $this->successWithResource(
'plugins.install_success',
new PluginResource($pluginInfo)
);
} else {
return $this->error('plugins.install_failed');
}
} catch (ValidationException $e) {
// Service에서 이미 번역된 메시지를 errors에 포함하므로
// 첫 번째 에러를 top-level message로 직접 사용 (이중 래핑 방지)
$firstError = collect($e->errors())->flatten()->first()
?? __('plugins.install_failed');
return ResponseHelper::error($firstError, 422, $e->errors());
} catch (\Exception $e) {
return $this->error('plugins.installation_failed', 500, $e->getMessage(), [
'error' => $e->getMessage(),
]);
}
}
/**
* 플러그인을 활성화합니다.
*
* force 파라미터가 없고 필요한 의존성이 충족되지 않은 경우 경고를 반환합니다.
*
* @param ActivatePluginRequest $request 플러그인 활성화 요청 데이터
* @return JsonResponse 활성화된 플러그인 정보를 포함한 JSON 응답
*/
public function activate(ActivatePluginRequest $request): JsonResponse
{
try {
$validated = $request->validated();
$pluginName = $validated['plugin_name'];
$force = $validated['force'] ?? false;
$result = $this->pluginService->activatePlugin($pluginName, $force);
// 경고 응답인 경우 (필요 의존성 미충족) - 활성화 실패로 처리
if (isset($result['warning']) && $result['warning'] === true) {
return $this->error('plugins.activate_warning', 409, [
'warning' => true,
'missing_modules' => $result['missing_modules'] ?? [],
'missing_plugins' => $result['missing_plugins'] ?? [],
'message' => $result['message'],
]);
}
if ($result['success']) {
$pluginInfo = $result['plugin_info'] ?? null;
if ($pluginInfo) {
return $this->successWithResource(
'plugins.activate_success',
new PluginResource($pluginInfo)
);
}
return $this->success('plugins.activate_success', $result);
} else {
return $this->error('plugins.activate_failed');
}
} catch (ValidationException $e) {
return $this->error(
'plugins.activate_validation_failed',
422,
$e->errors()
);
} catch (\Exception $e) {
return $this->error(
'plugins.activate_error',
500,
config('app.debug') ? $e->getMessage() : null
);
}
}
/**
* 플러그인을 비활성화합니다.
*
* force 파라미터가 없고 의존하는 확장이 있는 경우 경고를 반환합니다.
*
* @param DeactivatePluginRequest $request 플러그인 비활성화 요청 데이터
* @return JsonResponse 비활성화된 플러그인 정보를 포함한 JSON 응답
*/
public function deactivate(DeactivatePluginRequest $request): JsonResponse
{
try {
$validated = $request->validated();
$pluginName = $validated['plugin_name'];
$force = $validated['force'] ?? false;
$result = $this->pluginService->deactivatePlugin($pluginName, $force);
// 경고 응답인 경우 (의존 확장 존재) - 비활성화 실패로 처리
if (isset($result['warning']) && $result['warning'] === true) {
return $this->error('plugins.deactivate_warning', 409, [
'warning' => true,
'dependent_templates' => $result['dependent_templates'] ?? [],
'dependent_modules' => $result['dependent_modules'] ?? [],
'dependent_plugins' => $result['dependent_plugins'] ?? [],
'message' => $result['message'],
]);
}
if ($result['success']) {
$pluginInfo = $result['plugin_info'] ?? null;
if ($pluginInfo) {
return $this->successWithResource(
'plugins.deactivate_success',
new PluginResource($pluginInfo)
);
}
return $this->success('plugins.deactivate_success', $result);
} else {
return $this->error('plugins.deactivate_failed');
}
} catch (ValidationException $e) {
return $this->error(
'plugins.deactivate_validation_failed',
422,
$e->errors()
);
} catch (\Exception $e) {
return $this->error(
'plugins.deactivate_error',
500,
config('app.debug') ? $e->getMessage() : null
);
}
}
/**
* 플러그인에 의존하는 템플릿 목록을 조회합니다.
*
* @param string $identifier 플러그인 식별자
* @return JsonResponse 의존 템플릿 목록을 포함한 JSON 응답
*/
public function dependentTemplates(string $identifier): JsonResponse
{
try {
$dependentTemplates = $this->templateService->getTemplatesDependingOnPlugin($identifier);
return $this->success('plugin.dependent_templates_success', [
'data' => $dependentTemplates,
'total' => count($dependentTemplates),
]);
} catch (\Exception $e) {
return $this->error('plugin.dependent_templates_failed', 500, $e->getMessage());
}
}
/**
* 플러그인 삭제 시 삭제될 데이터 정보를 조회합니다.
*
* @param string $pluginName 플러그인명
* @return JsonResponse 삭제 정보를 포함한 JSON 응답
*/
public function uninstallInfo(string $pluginName): JsonResponse
{
try {
$uninstallInfo = $this->pluginService->getPluginUninstallInfo($pluginName);
if (! $uninstallInfo) {
return $this->error('plugins.not_found', 404, null, ['plugin' => $pluginName]);
}
return $this->success('plugins.uninstall_info_success', $uninstallInfo);
} catch (\Exception $e) {
return $this->error('plugins.uninstall_info_failed', 500, $e->getMessage());
}
}
/**
* 플러그인을 시스템에서 제거합니다.
*
* @param UninstallPluginRequest $request 플러그인 제거 요청 데이터
* @return JsonResponse 제거 결과 JSON 응답
*/
public function uninstall(UninstallPluginRequest $request): JsonResponse
{
try {
$validated = $request->validated();
$pluginName = $validated['plugin_name'];
$deleteData = $validated['delete_data'] ?? false;
$result = $this->pluginService->uninstallPlugin($pluginName, $deleteData);
if ($result) {
return $this->success('plugins.uninstall_success');
} else {
return $this->error('plugins.uninstall_failed');
}
} catch (ValidationException $e) {
return $this->error(
'plugins.uninstall_validation_failed',
422,
$e->errors()
);
} catch (\Exception $e) {
return $this->error(
'plugins.uninstall_error',
500,
config('app.debug') ? $e->getMessage() : null
);
}
}
/**
* ZIP 파일에서 플러그인을 설치합니다.
*
* @param InstallPluginFromFileRequest $request 파일 설치 요청 데이터
* @return JsonResponse 설치된 플러그인 정보를 포함한 JSON 응답
*/
public function installFromFile(InstallPluginFromFileRequest $request): JsonResponse
{
try {
$file = $request->file('file');
$plugin = $this->pluginService->installFromZipFile($file);
return $this->successWithResource(
'plugin.install_success',
new PluginResource($plugin),
201
);
} catch (\RuntimeException $e) {
return $this->error($e->getMessage(), 422);
} catch (\Exception $e) {
return $this->error('plugin.install_failed', 500, ['error' => $e->getMessage()]);
}
}
/**
* GitHub 저장소에서 플러그인을 설치합니다.
*
* @param InstallPluginFromGithubRequest $request GitHub 설치 요청 데이터
* @return JsonResponse 설치된 플러그인 정보를 포함한 JSON 응답
*/
public function installFromGithub(InstallPluginFromGithubRequest $request): JsonResponse
{
try {
$githubUrl = $request->validated()['github_url'];
$plugin = $this->pluginService->installFromGithub($githubUrl);
return $this->successWithResource(
'plugin.install_success',
new PluginResource($plugin),
201
);
} catch (\RuntimeException $e) {
return $this->error($e->getMessage(), 422);
} catch (\Exception $e) {
return $this->error('plugin.install_failed', 500, ['error' => $e->getMessage()]);
}
}
/**
* 설치된 모든 플러그인의 업데이트를 확인합니다.
*
* @return JsonResponse 업데이트 확인 결과 JSON 응답
*/
public function checkUpdates(): JsonResponse
{
try {
$result = $this->pluginService->checkForUpdates();
return $this->success('plugins.check_updates_success', $result);
} catch (ValidationException $e) {
return $this->error('plugins.check_updates_failed', 422, $e->errors());
} catch (\Exception $e) {
return $this->error('plugins.check_updates_failed', 500, $e->getMessage());
}
}
/**
* 특정 플러그인을 업데이트합니다.
*
* @param string $pluginName 업데이트할 플러그인 identifier
* @return JsonResponse 업데이트 결과 JSON 응답
*/
public function performUpdate(string $pluginName): JsonResponse
{
try {
$result = $this->pluginService->updatePlugin($pluginName);
$pluginInfo = $result['plugin_info'] ?? null;
if ($pluginInfo) {
return $this->successWithResource(
'plugins.update_success',
new PluginResource($pluginInfo)
);
}
return $this->success('plugins.update_success', $result);
} catch (ValidationException $e) {
// Service/Manager에서 이미 번역된 메시지를 errors에 포함하므로
// 첫 번째 에러를 top-level message로 직접 사용 (이중 래핑 방지)
$firstError = collect($e->errors())->flatten()->first()
?? __('plugins.errors.update_failed', ['plugin' => $pluginName, 'error' => '']);
return ResponseHelper::error($firstError, 422, $e->errors());
} catch (\Exception $e) {
return $this->error('plugins.errors.update_failed', 500, $e->getMessage(), [
'plugin' => $pluginName,
'error' => $e->getMessage(),
]);
}
}
/**
* 플러그인 레이아웃을 갱신합니다.
*
* 플러그인의 레이아웃 파일을 다시 읽어 DB에 동기화합니다.
* 파일에서 변경된 레이아웃은 업데이트되고, 삭제된 레이아웃은 DB에서도 삭제됩니다.
*
* @param RefreshPluginLayoutsRequest $request 레이아웃 갱신 요청 데이터
* @return JsonResponse 갱신 결과 JSON 응답
*/
public function refreshLayouts(RefreshPluginLayoutsRequest $request): JsonResponse
{
try {
$pluginName = $request->validated()['plugin_name'];
$result = $this->pluginService->refreshPluginLayouts($pluginName);
if ($result['success']) {
return $this->success('plugins.refresh_layouts_success', [
'layouts_refreshed' => $result['layouts_refreshed'],
'created' => $result['created'],
'updated' => $result['updated'],
'deleted' => $result['deleted'],
'unchanged' => $result['unchanged'],
]);
} else {
return $this->error('plugins.refresh_layouts_failed');
}
} catch (ValidationException $e) {
return $this->error(
'plugins.refresh_layouts_validation_failed',
422,
$e->errors()
);
} catch (\Exception $e) {
return $this->error(
'plugins.refresh_layouts_error',
500,
config('app.debug') ? $e->getMessage() : null
);
}
}
/**
* 특정 플러그인의 변경 내역(changelog)을 조회합니다.
*
* @param Request $request HTTP 요청
* @param string $identifier 플러그인 식별자
* @return JsonResponse 변경 내역을 포함한 JSON 응답
*/
public function changelog(ChangelogRequest $request, string $identifier): JsonResponse
{
try {
$validated = $request->validated();
$changelog = $this->pluginService->getPluginChangelog(
$identifier,
$validated['source'] ?? null,
$validated['from_version'] ?? null,
$validated['to_version'] ?? null,
);
return $this->success('plugin.fetch_success', ['changelog' => $changelog]);
} catch (\Exception $e) {
return $this->error('plugin.fetch_failed', 500, $e->getMessage());
}
}
/**
* 플러그인의 라이선스 파일 내용을 반환합니다.
*
* @param string $identifier 플러그인 식별자
* @return JsonResponse
*/
public function license(string $identifier): JsonResponse
{
if (! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $identifier)) {
return $this->error('plugins.license_not_found', 404);
}
$content = $this->licenseService->getExtensionLicense('plugins', $identifier);
if ($content === null) {
return $this->error('plugins.license_not_found', 404);
}
return $this->success('plugins.fetch_success', [
'content' => $content,
]);
}
}
@@ -0,0 +1,91 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Admin\UpdatePluginSettingsRequest;
use App\Services\PluginSettingsService;
use Illuminate\Http\JsonResponse;
/**
* 플러그인 설정 API 컨트롤러
*
* 플러그인의 설정 조회, 수정, 레이아웃 조회 기능을 제공합니다.
*/
class PluginSettingsController extends AdminBaseController
{
/**
* PluginSettingsController 생성자
*
* @param PluginSettingsService $pluginSettingsService 플러그인 설정 서비스
*/
public function __construct(
private PluginSettingsService $pluginSettingsService
) {
parent::__construct();
}
/**
* 플러그인 설정을 조회합니다.
*
* @param string $identifier 플러그인 식별자
* @return JsonResponse 설정 데이터
*/
public function show(string $identifier): JsonResponse
{
$settings = $this->pluginSettingsService->get($identifier);
if ($settings === null) {
return $this->notFound('plugins.not_found');
}
return $this->success('messages.success', $settings);
}
/**
* 플러그인 설정을 업데이트합니다.
*
* @param UpdatePluginSettingsRequest $request 검증된 요청
* @param string $identifier 플러그인 식별자
* @return JsonResponse 업데이트 결과
*/
public function update(UpdatePluginSettingsRequest $request, string $identifier): JsonResponse
{
// validated()가 빈 배열이면 all()에서 설정값을 가져옴
// (PluginManager에 등록되지 않은 플러그인의 경우)
$settings = $request->validated();
if (empty($settings)) {
$settings = $request->all();
}
$result = $this->pluginSettingsService->save($identifier, $settings);
if (! $result) {
return $this->error('plugins.settings.update_failed', 500);
}
return $this->success(
'plugins.settings.updated',
$this->pluginSettingsService->get($identifier)
);
}
/**
* 플러그인 설정 레이아웃을 조회합니다.
*
* 설정 페이지 UI 구성과 설정 스키마를 반환합니다.
*
* @param string $identifier 플러그인 식별자
* @return JsonResponse 레이아웃 데이터
*/
public function layout(string $identifier): JsonResponse
{
$layout = $this->pluginSettingsService->getLayout($identifier);
if ($layout === null) {
return $this->notFound('plugins.not_found');
}
return $this->success('messages.success', $layout);
}
}
@@ -0,0 +1,202 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Exceptions\ExtensionOwnedRoleDeleteException;
use App\Exceptions\SystemRoleDeleteException;
use App\Helpers\PermissionHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Role\RoleListRequest;
use App\Http\Requests\Role\StoreRoleRequest;
use App\Http\Requests\Role\UpdateRoleRequest;
use App\Http\Resources\RoleCollection;
use App\Http\Resources\RoleResource;
use App\Models\Role;
use App\Services\RoleService;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
/**
* 관리자용 역할 관리 컨트롤러
*
* 관리자가 시스템 역할들을 관리할 수 있는 기능을 제공합니다.
*/
class RoleController extends AdminBaseController
{
public function __construct(
private RoleService $roleService
) {
parent::__construct();
}
/**
* 페이지네이션된 역할 목록을 조회합니다.
*
* @param RoleListRequest $request 역할 목록 요청 데이터
* @return JsonResponse 역할 목록을 포함한 JSON 응답
*/
public function index(RoleListRequest $request): JsonResponse
{
try {
$filters = $request->only(['search', 'is_active']);
$perPage = $request->input('per_page', 20);
$roles = $this->roleService->getPaginatedRoles($filters, $perPage);
return $this->successWithResource(
'role.fetch_success',
new RoleCollection($roles)
);
} catch (Exception $e) {
return $this->error('role.fetch_failed', 500, $e->getMessage());
}
}
/**
* 활성화된 역할 목록을 조회합니다. (선택 옵션용)
*
* core.permissions.read 권한 보유 시 전체 활성 역할을 반환하고,
* 미보유 시 현재 사용자에게 부여된 역할만 반환합니다.
*
* @param Request $request HTTP 요청 객체
* @return JsonResponse 활성화된 역할 목록을 포함한 JSON 응답
*/
public function active(Request $request): JsonResponse
{
try {
/** @var \App\Models\User $user */
$user = $request->user();
// 역할 관리 권한(core.permissions.read) 보유 → 전체 활성 역할 (사용자 관리용)
// 미보유 → 자기에게 부여된 역할만 (자기 정보 수정 폼 표시용)
$roles = PermissionHelper::check('core.permissions.read')
? $this->roleService->getActiveRoles()
: $this->roleService->getUserActiveRoles($user);
return $this->success('role.fetch_success', [
'data' => RoleResource::collection($roles),
'abilities' => [
'can_assign_roles' => PermissionHelper::check('core.permissions.update'),
],
]);
} catch (Exception $e) {
return $this->error('role.fetch_failed', 500, $e->getMessage());
}
}
/**
* 특정 역할의 상세 정보를 조회합니다.
*
* @param Role $role 조회할 역할 모델
* @return JsonResponse 역할 상세 정보를 포함한 JSON 응답
*/
public function show(Role $role): JsonResponse
{
try {
$role = $this->roleService->getRoleWithPermissions($role);
return $this->successWithResource(
'role.fetch_success',
new RoleResource($role)
);
} catch (Exception $e) {
return $this->error('role.fetch_failed', 500, $e->getMessage());
}
}
/**
* 새로운 역할을 생성합니다.
*
* @param StoreRoleRequest $request 역할 생성 요청 데이터
* @return JsonResponse 생성된 역할 정보를 포함한 JSON 응답
*/
public function store(StoreRoleRequest $request): JsonResponse
{
try {
$role = $this->roleService->createRole($request->validated());
return $this->successWithResource(
'role.create_success',
new RoleResource($role),
201
);
} catch (ValidationException $e) {
return $this->error('role.create_failed', 422, $e->errors());
} catch (Exception $e) {
return $this->error('role.create_failed', 500, $e->getMessage());
}
}
/**
* 기존 역할 정보를 수정합니다.
*
* @param UpdateRoleRequest $request 역할 수정 요청 데이터
* @param Role $role 수정할 역할 모델
* @return JsonResponse 수정된 역할 정보를 포함한 JSON 응답
*/
public function update(UpdateRoleRequest $request, Role $role): JsonResponse
{
try {
$updatedRole = $this->roleService->updateRole($role, $request->validated());
return $this->successWithResource(
'role.update_success',
new RoleResource($updatedRole)
);
} catch (ValidationException $e) {
return $this->error('role.update_failed', 422, $e->errors());
} catch (Exception $e) {
return $this->error('role.update_failed', 500, $e->getMessage());
}
}
/**
* 역할의 활성화 상태를 토글합니다.
*
* @param Role $role 토글할 역할 모델
* @return JsonResponse 토글 결과를 포함한 JSON 응답
*/
public function toggleStatus(Role $role): JsonResponse
{
try {
$result = $this->roleService->toggleRoleStatus($role);
if ($result) {
$role->refresh();
$role->loadCount('users');
return $this->successWithResource(
'role.update_success',
new RoleResource($role)
);
} else {
return $this->error('role.update_failed');
}
} catch (Exception $e) {
return $this->error('role.update_failed', 500, $e->getMessage());
}
}
/**
* 역할을 삭제합니다.
*
* @param Role $role 삭제할 역할 모델
* @return JsonResponse 삭제 결과 JSON 응답
*/
public function destroy(Role $role): JsonResponse
{
try {
$this->roleService->deleteRole($role);
return $this->success('role.delete_success');
} catch (SystemRoleDeleteException $e) {
return $this->error('role.system_role_delete_error', 403, $e->getMessage());
} catch (ExtensionOwnedRoleDeleteException $e) {
return $this->error('role.extension_owned_role_delete_error', 403, $e->getMessage());
} catch (Exception $e) {
return $this->error('role.delete_failed', 500, $e->getMessage());
}
}
}
@@ -0,0 +1,297 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Schedule\BulkDeleteScheduleRequest;
use App\Http\Requests\Schedule\BulkUpdateScheduleStatusRequest;
use App\Http\Requests\Schedule\CreateScheduleRequest;
use App\Http\Requests\Schedule\ScheduleHistoryListRequest;
use App\Http\Requests\Schedule\ScheduleListRequest;
use App\Http\Requests\Schedule\UpdateScheduleRequest;
use App\Http\Resources\ScheduleCollection;
use App\Http\Resources\ScheduleHistoryCollection;
use App\Http\Resources\ScheduleHistoryResource;
use App\Http\Resources\ScheduleResource;
use App\Models\Schedule;
use App\Services\ScheduleService;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
/**
* 관리자용 스케줄 관리 컨트롤러
*
* 관리자가 시스템 스케줄(예약 작업)을 관리할 수 있는 기능을 제공합니다.
*/
class ScheduleController extends AdminBaseController
{
public function __construct(
private ScheduleService $scheduleService
) {
parent::__construct();
}
/**
* 필터링된 스케줄 목록을 조회합니다.
*
* @param ScheduleListRequest $request 스케줄 목록 요청 데이터
* @return JsonResponse 스케줄 목록과 통계 정보를 포함한 JSON 응답
*/
public function index(ScheduleListRequest $request): JsonResponse
{
try {
$filters = $request->validated();
$schedules = $this->scheduleService->getPaginatedSchedules($filters);
$statistics = $this->scheduleService->getStatistics();
$collection = new ScheduleCollection($schedules);
return $this->success(
'schedule.fetch_success',
$collection->withStatistics($statistics)
);
} catch (Exception $e) {
return $this->error('schedule.fetch_failed', 500, $e->getMessage());
}
}
/**
* 새로운 스케줄을 생성합니다.
*
* @param CreateScheduleRequest $request 스케줄 생성 요청 데이터
* @return JsonResponse 생성된 스케줄 정보를 포함한 JSON 응답
*/
public function store(CreateScheduleRequest $request): JsonResponse
{
try {
$schedule = $this->scheduleService->create($request->validatedWithCreator());
return $this->successWithResource(
'schedule.create_success',
new ScheduleResource($schedule),
201
);
} catch (ValidationException $e) {
return $this->error('schedule.create_failed', 422, $e->errors());
} catch (Exception $e) {
return $this->error('schedule.create_failed', 500, $e->getMessage());
}
}
/**
* 특정 스케줄의 상세 정보를 조회합니다.
*
* @param Schedule $schedule 조회할 스케줄 모델
* @return JsonResponse 스케줄 상세 정보를 포함한 JSON 응답
*/
public function show(Schedule $schedule): JsonResponse
{
try {
$schedule->load('creator');
return $this->successWithResource(
'schedule.fetch_success',
new ScheduleResource($schedule)
);
} catch (Exception $e) {
return $this->error('schedule.fetch_failed', 500, $e->getMessage());
}
}
/**
* 기존 스케줄 정보를 수정합니다.
*
* @param UpdateScheduleRequest $request 스케줄 수정 요청 데이터
* @param Schedule $schedule 수정할 스케줄 모델
* @return JsonResponse 수정된 스케줄 정보를 포함한 JSON 응답
*/
public function update(UpdateScheduleRequest $request, Schedule $schedule): JsonResponse
{
try {
$updatedSchedule = $this->scheduleService->update($schedule, $request->validated());
return $this->successWithResource(
'schedule.update_success',
new ScheduleResource($updatedSchedule)
);
} catch (ValidationException $e) {
return $this->error('schedule.update_failed', 422, $e->errors());
} catch (Exception $e) {
return $this->error('schedule.update_failed', 500, $e->getMessage());
}
}
/**
* 스케줄을 삭제합니다.
*
* @param Schedule $schedule 삭제할 스케줄 모델
* @return JsonResponse 삭제 결과 JSON 응답
*/
public function destroy(Schedule $schedule): JsonResponse
{
try {
$scheduleId = $schedule->id;
$result = $this->scheduleService->delete($schedule);
if ($result) {
return $this->success('schedule.delete_success');
} else {
return $this->error('schedule.delete_failed');
}
} catch (ValidationException $e) {
return $this->error('schedule.delete_failed', 422, $e->errors());
} catch (Exception $e) {
return $this->error('schedule.delete_failed', 500, $e->getMessage());
}
}
/**
* 스케줄을 즉시 실행합니다.
*
* @param Schedule $schedule 실행할 스케줄 모델
* @return JsonResponse 실행 결과를 포함한 JSON 응답
*/
public function run(Schedule $schedule): JsonResponse
{
try {
$history = $this->scheduleService->runSchedule($schedule, Auth::id());
return $this->successWithResource(
'schedule.run_success',
new ScheduleHistoryResource($history)
);
} catch (ValidationException $e) {
return $this->error('schedule.run_failed', 422, $e->errors());
} catch (Exception $e) {
return $this->error('schedule.run_failed', 500, $e->getMessage());
}
}
/**
* 스케줄을 복제합니다.
*
* @param Schedule $schedule 복제할 스케줄 모델
* @return JsonResponse 복제된 스케줄 정보를 포함한 JSON 응답
*/
public function duplicate(Schedule $schedule): JsonResponse
{
try {
$duplicated = $this->scheduleService->duplicate($schedule);
return $this->successWithResource(
'schedule.duplicate_success',
new ScheduleResource($duplicated),
201
);
} catch (Exception $e) {
return $this->error('schedule.duplicate_failed', 500, $e->getMessage());
}
}
/**
* 여러 스케줄의 상태를 일괄 변경합니다.
*
* @param BulkUpdateScheduleStatusRequest $request 일괄 상태 변경 요청 데이터
* @return JsonResponse 변경 결과를 포함한 JSON 응답
*/
public function bulkUpdateStatus(BulkUpdateScheduleStatusRequest $request): JsonResponse
{
try {
$validated = $request->validated();
$result = $this->scheduleService->bulkUpdateStatus($validated['ids'], $validated['is_active']);
return $this->success(
'schedule.bulk_status_updated',
$result
);
} catch (ValidationException $e) {
return $this->error('schedule.bulk_update_status_failed', 422, $e->errors());
} catch (Exception $e) {
return $this->error('schedule.bulk_update_status_failed', 500, $e->getMessage());
}
}
/**
* 여러 스케줄을 일괄 삭제합니다.
*
* @param BulkDeleteScheduleRequest $request 일괄 삭제 요청 데이터
* @return JsonResponse 삭제 결과를 포함한 JSON 응답
*/
public function bulkDelete(BulkDeleteScheduleRequest $request): JsonResponse
{
try {
$validated = $request->validated();
$result = $this->scheduleService->bulkDelete($validated['ids']);
return $this->success(
'schedule.bulk_delete_success',
$result
);
} catch (ValidationException $e) {
return $this->error('schedule.bulk_delete_failed', 422, $e->errors());
} catch (Exception $e) {
return $this->error('schedule.bulk_delete_failed', 500, $e->getMessage());
}
}
/**
* 스케줄 관련 통계 정보를 조회합니다.
*
* @return JsonResponse 스케줄 통계 데이터를 포함한 JSON 응답
*/
public function statistics(): JsonResponse
{
try {
$statistics = $this->scheduleService->getStatistics();
return $this->success('schedule.statistics_success', $statistics);
} catch (Exception $e) {
return $this->error('schedule.statistics_failed', 500, $e->getMessage());
}
}
/**
* 특정 스케줄의 실행 이력을 조회합니다.
*
* @param Schedule $schedule 조회할 스케줄 모델
* @param ScheduleHistoryListRequest $request 이력 목록 요청 데이터
* @return JsonResponse 실행 이력 목록을 포함한 JSON 응답
*/
public function history(Schedule $schedule, ScheduleHistoryListRequest $request): JsonResponse
{
try {
$filters = $request->validated();
$histories = $this->scheduleService->getHistory($schedule->id, $filters);
return $this->success(
'schedule.history_fetch_success',
new ScheduleHistoryCollection($histories)
);
} catch (Exception $e) {
return $this->error('schedule.history_fetch_failed', 500, $e->getMessage());
}
}
/**
* 실행 이력을 삭제합니다.
*
* @param int $historyId 삭제할 이력 ID
* @return JsonResponse 삭제 결과 JSON 응답
*/
public function deleteHistory(int $historyId): JsonResponse
{
try {
$result = $this->scheduleService->deleteHistory($historyId);
if ($result) {
return $this->success('schedule.history_delete_success');
} else {
return $this->error('schedule.history_not_found', 404);
}
} catch (Exception $e) {
return $this->error('schedule.history_delete_failed', 500, $e->getMessage());
}
}
}
@@ -0,0 +1,114 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Admin\SeoCacheClearRequest;
use App\Seo\Contracts\SeoCacheManagerInterface;
use App\Seo\SeoCacheStatsService;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
/**
* SEO 캐시 관리 컨트롤러
*
* SEO 캐시 통계 조회, 캐시 삭제, 워밍업, 캐시된 URL 목록을 제공합니다.
*/
class SeoCacheController extends AdminBaseController
{
public function __construct(
private SeoCacheStatsService $statsService,
private SeoCacheManagerInterface $cacheManager
) {
parent::__construct();
}
/**
* SEO 캐시 통계를 조회합니다.
*
* 전체 통계, 레이아웃별 통계, 모듈별 통계를 반환합니다.
*
* @return JsonResponse 캐시 통계 데이터를 포함한 JSON 응답
*/
public function stats(): JsonResponse
{
try {
$since = Carbon::now()->subDays(7);
$data = [
'overall' => $this->statsService->getStats($since),
'by_layout' => $this->statsService->getStatsByLayout($since),
'by_module' => $this->statsService->getStatsByModule($since),
];
return $this->success('messages.success', $data);
} catch (\Exception $e) {
return $this->error('messages.error_occurred', 500, $e->getMessage());
}
}
/**
* SEO 캐시를 삭제합니다.
*
* 레이아웃 또는 모듈 지정 시 해당 캐시만, 미지정 시 전체 캐시를 삭제합니다.
*
* @param SeoCacheClearRequest $request 캐시 삭제 요청
* @return JsonResponse 삭제 결과를 포함한 JSON 응답
*/
public function clearCache(SeoCacheClearRequest $request): JsonResponse
{
try {
$layout = $request->validated('layout');
if ($layout) {
$count = $this->cacheManager->invalidateByLayout($layout);
return $this->success('messages.success', ['cleared' => $count]);
}
$this->cacheManager->clearAll();
return $this->success('messages.success', ['cleared' => 'all']);
} catch (\Exception $e) {
return $this->error('messages.error_occurred', 500, $e->getMessage());
}
}
/**
* SEO 캐시 워밍업을 실행합니다.
*
* 모든 SEO 레이아웃을 사전 렌더링합니다. (Phase 5에서 구현 예정)
*
* @return JsonResponse 워밍업 시작 결과를 포함한 JSON 응답
*/
public function warmup(): JsonResponse
{
try {
// Phase 5의 SeoDeclarationCollector 구현 후 실제 워밍업 로직 추가 예정
return $this->success('messages.success', [
'status' => 'dispatched',
'message' => __('seo.warmup_dispatched'),
]);
} catch (\Exception $e) {
return $this->error('messages.error_occurred', 500, $e->getMessage());
}
}
/**
* 캐시된 URL 목록을 조회합니다.
*
* SeoCacheManager의 인덱스에서 현재 캐시된 URL 목록을 반환합니다.
*
* @return JsonResponse 캐시된 URL 목록을 포함한 JSON 응답
*/
public function cachedUrls(): JsonResponse
{
try {
$urls = $this->cacheManager->getCachedUrls();
return $this->success('messages.success', ['urls' => $urls, 'count' => count($urls)]);
} catch (\Exception $e) {
return $this->error('messages.error_occurred', 500, $e->getMessage());
}
}
}
@@ -0,0 +1,339 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Settings\RegenerateAppKeyRequest;
use App\Http\Requests\Settings\SaveSettingsRequest;
use App\Http\Requests\Settings\TestDriverConnectionRequest;
use App\Http\Requests\Settings\TestMailRequest;
use App\Http\Requests\Settings\UpdateSettingRequest;
use App\Http\Resources\SettingsResource;
use App\Services\DriverConnectionTester;
use App\Services\SettingsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
/**
* 관리자용 시스템 설정 컨트롤러
*
* 관리자가 시스템 설정을 관리할 수 있는 기능을 제공합니다.
*/
class SettingsController extends AdminBaseController
{
public function __construct(
private SettingsService $settingsService,
private DriverConnectionTester $driverConnectionTester
) {
parent::__construct();
}
/**
* 모든 시스템 설정을 조회합니다.
*
* @return JsonResponse 시스템 설정 목록을 포함한 JSON 응답
*/
public function index(): JsonResponse
{
try {
$settings = $this->settingsService->getAllSettings();
return $this->success('settings.fetch_success',
(new SettingsResource($settings))->toArray(request())
);
} catch (\Exception $e) {
return $this->error('settings.fetch_failed', 500, $e->getMessage());
}
}
/**
* 여러 시스템 설정을 일괄 저장합니다.
*
* 저장 성공 시 전체 settings를 응답에 포함하여
* 프론트엔드에서 새로고침 없이 전역 상태를 업데이트할 수 있도록 합니다.
*
* @param SaveSettingsRequest $request 설정 저장 요청 데이터
* @return JsonResponse 저장 결과 JSON 응답
*/
public function store(SaveSettingsRequest $request): JsonResponse
{
try {
$result = $this->settingsService->saveSettings($request->validated());
if ($result) {
// 저장 후 전체 설정 반환 (관리자 UI 상태 업데이트용)
$allSettings = $this->settingsService->getAllSettings();
return $this->success('settings.save_success', [
'settings' => $allSettings,
]);
} else {
return $this->error('settings.save_failed');
}
} catch (ValidationException $e) {
return $this->error('settings.save_failed', 422, $e->errors());
} catch (\Exception $e) {
return $this->error('settings.save_error', 500, $e->getMessage());
}
}
/**
* 특정 키의 시스템 설정을 조회합니다.
*
* @param string $key 조회할 설정 키
* @return JsonResponse 설정 값을 포함한 JSON 응답
*/
public function show(string $key): JsonResponse
{
try {
$value = $this->settingsService->getSetting($key);
return $this->success('settings.fetch_success', [
'key' => $key,
'value' => $value,
]);
} catch (\Exception $e) {
return $this->error('settings.fetch_failed', 500, $e->getMessage());
}
}
/**
* 특정 키의 시스템 설정을 업데이트합니다.
*
* @param string $key 업데이트할 설정 키
* @param UpdateSettingRequest $request 설정 업데이트 요청 데이터
* @return JsonResponse 업데이트 결과 JSON 응답
*/
public function update(string $key, UpdateSettingRequest $request): JsonResponse
{
try {
$result = $this->settingsService->setSetting($key, $request->validated()['value']);
if ($result) {
return $this->success('settings.update_success');
} else {
return $this->error('settings.update_failed');
}
} catch (ValidationException $e) {
return $this->error('settings.update_failed', 422, $e->errors());
} catch (\Exception $e) {
return $this->error('settings.update_error', 500, $e->getMessage());
}
}
/**
* 시스템 환경 정보를 조회합니다.
*
* @return JsonResponse 시스템 정보를 포함한 JSON 응답
*/
public function systemInfo(): JsonResponse
{
try {
$systemInfo = $this->settingsService->getSystemInfo();
return $this->success('common.success', $systemInfo);
} catch (\Exception $e) {
return $this->error('common.error_occurred', 500, $e->getMessage());
}
}
/**
* 시스템 캐시를 정리합니다.
*
* @return JsonResponse 캐시 정리 결과 JSON 응답
*/
public function clearCache(): JsonResponse
{
try {
$result = $this->settingsService->clearCache();
if ($result) {
return $this->success('settings.cache_clear_success');
} else {
return $this->error('settings.cache_clear_failed');
}
} catch (\Exception $e) {
return $this->error('settings.cache_clear_error', 500, $e->getMessage());
}
}
/**
* 시스템을 최적화합니다 (캐시 생성).
*
* @return JsonResponse 최적화 결과 JSON 응답
*/
public function optimizeSystem(): JsonResponse
{
try {
$result = $this->settingsService->optimizeSystem();
if ($result) {
return $this->success('settings.optimize_success');
} else {
return $this->error('settings.optimize_failed');
}
} catch (\Exception $e) {
return $this->error('settings.optimize_error', 500, $e->getMessage());
}
}
/**
* 데이터베이스를 백업합니다.
*
* @return JsonResponse 백업 결과 JSON 응답
*/
public function backupDatabase(): JsonResponse
{
try {
$result = $this->settingsService->backupDatabase();
if ($result) {
return $this->success('settings.backup_success');
} else {
return $this->error('settings.backup_failed');
}
} catch (\Exception $e) {
return $this->error('settings.backup_error', 500, $e->getMessage());
}
}
/**
* 현재 앱 키를 조회합니다 (마스킹된 형태).
*
* @return JsonResponse 마스킹된 앱 키 JSON 응답
*/
public function getAppKey(): JsonResponse
{
try {
$maskedKey = $this->settingsService->maskAppKey();
return $this->success('common.success', [
'app_key' => $maskedKey,
]);
} catch (\Exception $e) {
return $this->error('common.error_occurred', 500, $e->getMessage());
}
}
/**
* 앱 키를 재생성합니다.
*
* @param RegenerateAppKeyRequest $request 앱 키 재생성 요청 데이터
* @return JsonResponse 재생성된 앱 키 JSON 응답
*/
public function regenerateAppKey(RegenerateAppKeyRequest $request): JsonResponse
{
try {
$result = $this->settingsService->regenerateAppKey($request->password);
if (! $result['success']) {
return $this->error($result['error'], 401);
}
return $this->success('settings.app_key_regenerated', [
'app_key' => $result['app_key'],
]);
} catch (\Exception $e) {
return $this->error('settings.app_key_regenerate_failed', 500, $e->getMessage());
}
}
/**
* 설정을 백업합니다.
*
* @return JsonResponse 백업 결과 JSON 응답
*/
public function backup(): JsonResponse
{
try {
$backupPath = $this->settingsService->backupSettings();
return $this->success('settings.backup_success', [
'backup_path' => $backupPath,
]);
} catch (\Exception $e) {
return $this->error('settings.backup_failed', 500, $e->getMessage());
}
}
/**
* 백업에서 설정을 복원합니다.
*
* @return JsonResponse 복원 결과 JSON 응답
*/
public function restore(Request $request): JsonResponse
{
try {
$backupPath = $request->input('backup_path');
if (empty($backupPath)) {
return $this->error('settings.backup_path_required', 422);
}
$result = $this->settingsService->restoreSettings($backupPath);
if ($result) {
return $this->success('settings.restore_success');
}
return $this->error('settings.restore_failed');
} catch (\Exception $e) {
return $this->error('settings.restore_error', 500, $e->getMessage());
}
}
/**
* 테스트 메일을 발송합니다.
*
* @param TestMailRequest $request 테스트 메일 발송 요청 데이터
* @return JsonResponse 발송 결과 JSON 응답
*/
public function testMail(TestMailRequest $request): JsonResponse
{
try {
$validated = $request->validated();
$toEmail = $validated['to_email'];
$mailSettings = collect($validated)->except('to_email')->filter(fn ($v) => $v !== null)->toArray();
$result = $this->settingsService->sendTestMail($toEmail, $mailSettings);
if ($result['success']) {
return $this->success($result['message'], [
'subject' => $result['subject'],
'body' => $result['body'],
]);
}
return $this->error($result['message'], 500, $result['error'] ?? null);
} catch (\Exception $e) {
return $this->error('settings.test_mail_error', 500, $e->getMessage());
}
}
/**
* 드라이버 연결을 테스트합니다.
*
* S3, Redis, Memcached, Websocket 등 외부 서비스 드라이버의
* 연결 상태를 테스트합니다.
*
* @param TestDriverConnectionRequest $request 드라이버 테스트 요청 데이터
* @return JsonResponse 테스트 결과 JSON 응답
*/
public function testDriverConnection(TestDriverConnectionRequest $request): JsonResponse
{
try {
$settings = $request->validated();
$result = $this->driverConnectionTester->testAll($settings);
if ($result['all_passed']) {
return $this->success('settings.driver_test_success', $result);
}
// 일부 테스트 실패 시에도 결과 반환 (성공 상태지만 all_passed가 false)
return $this->success('settings.driver_test_partial', $result);
} catch (\Exception $e) {
return $this->error('settings.driver_test_error', 500, $e->getMessage());
}
}
}
@@ -0,0 +1,472 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Helpers\PermissionHelper;
use App\Helpers\ResponseHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Template\ActivateTemplateRequest;
use App\Http\Requests\Template\DeactivateTemplateRequest;
use App\Http\Requests\Template\IndexTemplateRequest;
use App\Http\Requests\Template\InstallTemplateFromFileRequest;
use App\Http\Requests\Template\InstallTemplateFromGithubRequest;
use App\Http\Requests\Template\InstallTemplateRequest;
use App\Http\Requests\Template\PerformTemplateUpdateRequest;
use App\Http\Requests\Template\RefreshTemplateLayoutsRequest;
use App\Http\Requests\Template\UninstallTemplateRequest;
use App\Http\Requests\Extension\ChangelogRequest;
use App\Http\Resources\TemplateCollection;
use App\Http\Resources\TemplateResource;
use App\Services\LicenseService;
use App\Services\TemplateService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
/**
* 관리자용 템플릿 관리 컨트롤러
*
* 관리자가 시스템 템플릿을 설치, 활성화, 비활성화, 제거할 수 있는 기능을 제공합니다.
*/
class TemplateController extends AdminBaseController
{
public function __construct(
private TemplateService $templateService,
private LicenseService $licenseService
) {
parent::__construct();
}
/**
* 모든 템플릿 목록을 조회합니다 (설치된 템플릿과 미설치 템플릿 포함).
*
* 페이지네이션 및 다중 검색 조건을 지원합니다.
* - search: 단일 검색어 (이름, 식별자, 설명, 벤더 OR 검색)
* - filters: 다중 검색 조건 (AND 조건)
*
* @param IndexTemplateRequest $request 템플릿 목록 조회 요청
* @return JsonResponse 템플릿 목록을 포함한 JSON 응답
*/
public function index(IndexTemplateRequest $request): JsonResponse
{
try {
$validated = $request->validated();
$filters = [
'search' => $validated['search'] ?? null,
'filters' => $validated['filters'] ?? [],
'status' => $validated['status'] ?? null,
'type' => $validated['type'] ?? null,
];
$perPage = (int) ($validated['per_page'] ?? 12);
$page = (int) ($validated['page'] ?? 1);
$result = $this->templateService->getPaginatedTemplates($filters, $perPage, $page);
$collection = new TemplateCollection(collect($result['data']));
return $this->success('templates.fetch_success', [
'data' => $collection->toArray($request)['data'],
'pagination' => [
'total' => $result['total'],
'current_page' => $result['current_page'],
'last_page' => $result['last_page'],
'per_page' => $result['per_page'],
],
'meta' => $collection->with($request)['meta'],
'abilities' => [
'can_install' => PermissionHelper::check('core.templates.install', $request->user()),
'can_activate' => PermissionHelper::check('core.templates.activate', $request->user()),
'can_uninstall' => PermissionHelper::check('core.templates.uninstall', $request->user()),
],
]);
} catch (\Exception $e) {
return $this->error('templates.fetch_failed', 500, $e->getMessage());
}
}
/**
* 특정 템플릿의 상세 정보를 조회합니다.
*
* @param string $templateName 템플릿 식별자
* @return JsonResponse 템플릿 정보를 포함한 JSON 응답
*/
public function show(string $templateName): JsonResponse
{
try {
$templateInfo = $this->templateService->getTemplateInfo($templateName);
if (! $templateInfo) {
return $this->error('templates.not_found', 404, null, ['template' => $templateName]);
}
// 상세 정보는 toDetailArray() 메서드 사용
$resource = new TemplateResource($templateInfo);
return $this->success('templates.fetch_success', $resource->toDetailArray());
} catch (\Exception $e) {
return $this->error('templates.fetch_failed', 500, $e->getMessage());
}
}
/**
* 템플릿을 시스템에 설치합니다.
*
* @param InstallTemplateRequest $request 템플릿 설치 요청 데이터
* @return JsonResponse 설치된 템플릿 정보를 포함한 JSON 응답
*/
public function install(InstallTemplateRequest $request): JsonResponse
{
try {
$templateName = $request->validated()['template_name'];
$template = $this->templateService->installTemplate($templateName);
if ($template) {
return $this->successWithResource(
'templates.install_success',
new TemplateResource($template),
201
);
} else {
return $this->error('templates.install_failed');
}
} catch (ValidationException $e) {
// Service에서 이미 번역된 메시지를 errors에 포함하므로
// 첫 번째 에러를 top-level message로 직접 사용 (이중 래핑 방지)
$firstError = collect($e->errors())->flatten()->first()
?? __('templates.install_failed');
return ResponseHelper::error($firstError, 422, $e->errors());
} catch (\Exception $e) {
return $this->error('templates.errors.installation_failed', 500, $e->getMessage(), [
'error' => $e->getMessage(),
]);
}
}
/**
* 템플릿을 활성화합니다.
*
* force 파라미터가 없고 필요한 의존성이 충족되지 않은 경우 경고를 반환합니다.
*
* @param ActivateTemplateRequest $request 템플릿 활성화 요청 데이터
* @return JsonResponse 활성화된 템플릿 정보를 포함한 JSON 응답
*/
public function activate(ActivateTemplateRequest $request): JsonResponse
{
try {
$validated = $request->validated();
$templateName = $validated['template_name'];
$force = $validated['force'] ?? false;
$result = $this->templateService->activateTemplate($templateName, $force);
// 경고 응답인 경우 (필요 의존성 미충족) - 활성화 실패로 처리
if (isset($result['warning']) && $result['warning'] === true) {
return $this->error('templates.activate_warning', 409, [
'warning' => true,
'missing_modules' => $result['missing_modules'] ?? [],
'missing_plugins' => $result['missing_plugins'] ?? [],
'message' => $result['message'],
]);
}
if ($result['success']) {
$templateInfo = $result['template_info'] ?? null;
if ($templateInfo) {
return $this->successWithResource(
'templates.activate_success',
new TemplateResource($templateInfo)
);
}
return $this->success('templates.activate_success', $result);
} else {
return $this->error('templates.activate_failed');
}
} catch (ValidationException $e) {
return $this->error('templates.activate_failed', 422, $e->errors());
} catch (\Exception $e) {
return $this->error('templates.activate_failed', 500, $e->getMessage());
}
}
/**
* 템플릿을 비활성화합니다.
*
* @param DeactivateTemplateRequest $request 템플릿 비활성화 요청 데이터
* @return JsonResponse 비활성화된 템플릿 정보를 포함한 JSON 응답
*/
public function deactivate(DeactivateTemplateRequest $request): JsonResponse
{
try {
$templateName = $request->validated()['template_name'];
$template = $this->templateService->deactivateTemplate($templateName);
if ($template) {
return $this->successWithResource(
'templates.deactivate_success',
new TemplateResource($template)
);
} else {
return $this->error('templates.deactivate_failed');
}
} catch (ValidationException $e) {
return $this->error('templates.deactivate_failed', 422, $e->errors());
} catch (\Exception $e) {
return $this->error('templates.deactivate_failed', 500, $e->getMessage());
}
}
/**
* 템플릿을 시스템에서 제거합니다.
*
* @param UninstallTemplateRequest $request 템플릿 제거 요청 데이터
* @return JsonResponse 제거 결과 JSON 응답
*/
public function uninstall(UninstallTemplateRequest $request): JsonResponse
{
try {
$validated = $request->validated();
$templateName = $validated['template_name'];
$deleteData = $validated['delete_data'] ?? false;
$result = $this->templateService->uninstallTemplate($templateName, $deleteData);
if ($result) {
return $this->success('templates.uninstall_success');
} else {
return $this->error('templates.uninstall_failed');
}
} catch (ValidationException $e) {
return $this->error('templates.uninstall_failed', 422, $e->errors());
} catch (\Exception $e) {
return $this->error('templates.uninstall_failed', 500, $e->getMessage());
}
}
/**
* 템플릿 삭제 시 삭제될 데이터 정보를 조회합니다.
*
* @param string $templateName 템플릿명
* @return JsonResponse 삭제 정보를 포함한 JSON 응답
*/
public function uninstallInfo(string $templateName): JsonResponse
{
try {
$uninstallInfo = $this->templateService->getTemplateUninstallInfo($templateName);
if (! $uninstallInfo) {
return $this->error('templates.not_found', 404, null, ['template' => $templateName]);
}
return $this->success('templates.uninstall_info_success', $uninstallInfo);
} catch (\Exception $e) {
return $this->error('templates.uninstall_info_failed', 500, $e->getMessage());
}
}
/**
* ZIP 파일에서 템플릿을 설치합니다.
*
* @param InstallTemplateFromFileRequest $request 파일 설치 요청 데이터
* @return JsonResponse 설치된 템플릿 정보를 포함한 JSON 응답
*/
public function installFromFile(InstallTemplateFromFileRequest $request): JsonResponse
{
try {
$file = $request->file('file');
$template = $this->templateService->installFromZipFile($file);
return $this->successWithResource(
'templates.install_success',
new TemplateResource($template),
201
);
} catch (\RuntimeException $e) {
return $this->error($e->getMessage(), 422);
} catch (\Exception $e) {
return $this->error('templates.install_failed', 500, ['error' => $e->getMessage()]);
}
}
/**
* GitHub 저장소에서 템플릿을 설치합니다.
*
* @param InstallTemplateFromGithubRequest $request GitHub 설치 요청 데이터
* @return JsonResponse 설치된 템플릿 정보를 포함한 JSON 응답
*/
public function installFromGithub(InstallTemplateFromGithubRequest $request): JsonResponse
{
try {
$githubUrl = $request->validated()['github_url'];
$template = $this->templateService->installFromGithub($githubUrl);
return $this->successWithResource(
'templates.install_success',
new TemplateResource($template),
201
);
} catch (\RuntimeException $e) {
return $this->error($e->getMessage(), 422);
} catch (\Exception $e) {
return $this->error('templates.install_failed', 500, ['error' => $e->getMessage()]);
}
}
/**
* 템플릿의 레이아웃을 파일에서 다시 읽어 갱신합니다.
*
* @param RefreshTemplateLayoutsRequest $request 레이아웃 갱신 요청 데이터
* @return JsonResponse 갱신된 템플릿 정보를 포함한 JSON 응답
*/
public function refreshLayouts(RefreshTemplateLayoutsRequest $request): JsonResponse
{
try {
$templateName = $request->validated()['template_name'];
$template = $this->templateService->refreshTemplateLayouts($templateName);
if ($template) {
return $this->successWithResource(
'templates.refresh_layouts_success',
new TemplateResource($template)
);
} else {
return $this->error('templates.refresh_layouts_failed');
}
} catch (ValidationException $e) {
return $this->error('templates.refresh_layouts_failed', 422, $e->errors());
} catch (\Exception $e) {
return $this->error('templates.refresh_layouts_failed', 500, $e->getMessage());
}
}
/**
* 설치된 모든 템플릿의 업데이트를 확인합니다.
*
* @return JsonResponse 업데이트 확인 결과 JSON 응답
*/
public function checkUpdates(): JsonResponse
{
try {
$result = $this->templateService->checkForUpdates();
return $this->success('templates.check_updates_success', $result);
} catch (ValidationException $e) {
return $this->error('templates.check_updates_failed', 422, $e->errors());
} catch (\Exception $e) {
return $this->error('templates.check_updates_failed', 500, $e->getMessage());
}
}
/**
* 특정 템플릿의 수정된 레이아웃을 확인합니다.
*
* 업데이트 전 사용자가 수정한 레이아웃이 있는지 확인하여
* 레이아웃 전략(overwrite/keep) 선택에 참고할 수 있도록 합니다.
*
* @param string $templateName 템플릿 식별자
* @return JsonResponse 수정된 레이아웃 정보를 포함한 JSON 응답
*/
public function checkModifiedLayouts(string $templateName): JsonResponse
{
try {
$result = $this->templateService->checkModifiedLayouts($templateName);
return $this->success('templates.check_modified_layouts_success', $result);
} catch (ValidationException $e) {
return $this->error('templates.check_modified_layouts_failed', 422, $e->errors());
} catch (\Exception $e) {
return $this->error('templates.check_modified_layouts_failed', 500, $e->getMessage());
}
}
/**
* 특정 템플릿을 업데이트합니다.
*
* layout_strategy 파라미터로 레이아웃 처리 방식을 결정합니다:
* - overwrite: 모든 레이아웃을 새 버전으로 교체
* - keep: 사용자가 수정한 레이아웃을 유지
*
* @param PerformTemplateUpdateRequest $request 업데이트 요청 데이터
* @param string $templateName 업데이트할 템플릿 identifier
* @return JsonResponse 업데이트 결과 JSON 응답
*/
public function performUpdate(PerformTemplateUpdateRequest $request, string $templateName): JsonResponse
{
try {
$layoutStrategy = $request->validated()['layout_strategy'] ?? 'overwrite';
$result = $this->templateService->performVersionUpdate($templateName, $layoutStrategy);
$templateInfo = $result['template_info'] ?? null;
if ($templateInfo) {
return $this->successWithResource(
'templates.update_success',
new TemplateResource($templateInfo)
);
}
return $this->success('templates.update_success', $result);
} catch (ValidationException $e) {
// Service/Manager에서 이미 번역된 메시지를 errors에 포함하므로
// 첫 번째 에러를 top-level message로 직접 사용 (이중 래핑 방지)
$firstError = collect($e->errors())->flatten()->first()
?? __('templates.errors.update_failed', ['template' => $templateName, 'error' => '']);
return ResponseHelper::error($firstError, 422, $e->errors());
} catch (\Exception $e) {
return $this->error('templates.errors.update_failed', 500, $e->getMessage(), [
'template' => $templateName,
'error' => $e->getMessage(),
]);
}
}
/**
* 특정 템플릿의 변경 내역(changelog)을 조회합니다.
*
* @param Request $request HTTP 요청
* @param string $identifier 템플릿 식별자
* @return JsonResponse 변경 내역을 포함한 JSON 응답
*/
public function changelog(ChangelogRequest $request, string $identifier): JsonResponse
{
try {
$validated = $request->validated();
$changelog = $this->templateService->getTemplateChangelog(
$identifier,
$validated['source'] ?? null,
$validated['from_version'] ?? null,
$validated['to_version'] ?? null,
);
return $this->success('template.fetch_success', ['changelog' => $changelog]);
} catch (\Exception $e) {
return $this->error('template.fetch_failed', 500, $e->getMessage());
}
}
/**
* 템플릿의 라이선스 파일 내용을 반환합니다.
*
* @param string $identifier 템플릿 식별자
* @return JsonResponse
*/
public function license(string $identifier): JsonResponse
{
if (! preg_match('/^[a-z0-9][a-z0-9_-]*$/', $identifier)) {
return $this->error('templates.license_not_found', 404);
}
$content = $this->licenseService->getExtensionLicense('templates', $identifier);
if ($content === null) {
return $this->error('templates.license_not_found', 404);
}
return $this->success('templates.fetch_success', [
'content' => $content,
]);
}
}
@@ -0,0 +1,297 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Exceptions\CannotDeleteAdminException;
use App\Exceptions\CannotDeleteSuperAdminException;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\User\BulkUpdateUserStatusRequest;
use App\Http\Requests\User\CheckEmailRequest;
use App\Http\Requests\User\CreateUserRequest;
use App\Http\Requests\User\DeleteUserRequest;
use App\Http\Requests\User\SearchUserRequest;
use App\Http\Requests\User\UpdateLanguageRequest;
use App\Http\Requests\User\UpdateUserRequest;
use App\Http\Requests\User\UserListRequest;
use App\Http\Resources\UserCollection;
use App\Http\Resources\UserResource;
use App\Models\User;
use App\Services\UserService;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Validation\ValidationException;
/**
* 관리자용 사용자 관리 컨트롤러
*
* 관리자가 시스템 사용자들을 관리할 수 있는 기능을 제공합니다.
*/
class UserController extends AdminBaseController
{
public function __construct(
private UserService $userService
) {
parent::__construct();
}
/**
* 필터링된 사용자 목록을 조회합니다.
*
* @param UserListRequest $request 사용자 목록 요청 데이터
* @return JsonResponse 사용자 목록과 통계 정보를 포함한 JSON 응답
*/
public function index(UserListRequest $request): JsonResponse
{
try {
$filters = $request->validated();
$users = $this->userService->getPaginatedUsers($filters);
$statistics = $this->userService->getStatistics();
$collection = new UserCollection($users);
return $this->success(
'user.fetch_success',
$collection->withStatistics($statistics)
);
} catch (Exception $e) {
return $this->error('user.fetch_failed', 500, $e->getMessage());
}
}
/**
* 새로운 사용자를 생성합니다.
*
* @param CreateUserRequest $request 사용자 생성 요청 데이터
* @return JsonResponse 생성된 사용자 정보를 포함한 JSON 응답
*/
public function store(CreateUserRequest $request): JsonResponse
{
try {
$user = $this->userService->createUser($request->validated());
return $this->successWithResource(
'user.create_success',
new UserResource($user),
201
);
} catch (ValidationException $e) {
return $this->error('user.create_failed', 422, $e->errors());
} catch (Exception $e) {
return $this->error('user.create_failed', 500, $e->getMessage());
}
}
/**
* 특정 사용자의 상세 정보를 조회합니다.
*
* @param User $user 조회할 사용자 모델
* @return JsonResponse 사용자 상세 정보를 포함한 JSON 응답
*/
public function show(User $user): JsonResponse
{
try {
// 관계형 데이터 로드
$user->load(['modules', 'plugins', 'menus', 'roles', 'consents']);
$user->loadCount(['modules', 'plugins', 'menus']);
// withAdminInfo()를 호출하여 관리자용 상세 정보 및 모듈 훅 데이터 포함
$resource = new UserResource($user);
return $this->success(
'user.fetch_success',
$resource->withAdminInfo()
);
} catch (Exception $e) {
return $this->error('user.fetch_failed', 500, $e->getMessage());
}
}
/**
* 기존 사용자 정보를 수정합니다.
*
* @param UpdateUserRequest $request 사용자 수정 요청 데이터
* @param User $user 수정할 사용자 모델
* @return JsonResponse 수정된 사용자 정보를 포함한 JSON 응답
*/
public function update(UpdateUserRequest $request, User $user): JsonResponse
{
try {
$updatedUser = $this->userService->updateUser($user, $request->validated());
return $this->successWithResource(
'user.update_success',
new UserResource($updatedUser)
);
} catch (ValidationException $e) {
return $this->error('user.update_failed', 422, $e->errors());
} catch (Exception $e) {
return $this->error('user.update_failed', 500, $e->getMessage());
}
}
/**
* 사용자를 삭제합니다.
*
* @param DeleteUserRequest $request 사용자 삭제 요청 데이터
* @param User $user 삭제할 사용자 모델
* @return JsonResponse 삭제 결과 JSON 응답
*/
public function destroy(DeleteUserRequest $request, User $user): JsonResponse
{
try {
$result = $this->userService->deleteUser($user);
if ($result) {
return $this->success('user.delete_success');
} else {
return $this->error('user.delete_failed');
}
} catch (CannotDeleteSuperAdminException $e) {
return $this->error('exceptions.cannot_delete_super_admin', 422);
} catch (CannotDeleteAdminException $e) {
return $this->error('user.delete_admin_forbidden', 422);
} catch (ValidationException $e) {
return $this->error('user.delete_failed', 422, $e->errors());
} catch (Exception $e) {
return $this->error('user.delete_failed', 500);
}
}
/**
* 사용자 관련 통계 정보를 조회합니다.
*
* @return JsonResponse 사용자 통계 데이터를 포함한 JSON 응답
*/
public function statistics(): JsonResponse
{
try {
$statistics = $this->userService->getStatistics();
$languageDistribution = $this->userService->getUserLanguageDistribution();
$data = array_merge($statistics, [
'language_distribution' => $languageDistribution,
]);
return $this->success('user.statistics_success', $data);
} catch (Exception $e) {
return $this->error('user.statistics_failed', 500, $e->getMessage());
}
}
/**
* 최근 등록된 사용자들을 조회합니다.
*
* @return JsonResponse 최근 사용자 목록을 포함한 JSON 응답
*/
public function recent(): JsonResponse
{
try {
$users = $this->userService->getRecentUsers(10);
return $this->successWithResource(
'user.fetch_success',
UserResource::collection($users)
);
} catch (Exception $e) {
return $this->error('user.fetch_failed', 500, $e->getMessage());
}
}
/**
* 키워드로 사용자를 검색합니다. (이름, 닉네임, 이메일)
*
* @param SearchUserRequest $request 사용자 검색 요청 데이터
* @return JsonResponse 검색된 사용자 목록을 포함한 JSON 응답
*/
public function search(SearchUserRequest $request): JsonResponse
{
try {
$validated = $request->validated();
if (isset($validated['uuid'])) {
$user = $this->userService->getUserByUuid($validated['uuid']);
$users = $user ? collect([$user]) : collect();
} elseif (isset($validated['id'])) {
$user = $this->userService->getUserById($validated['id']);
$users = $user ? collect([$user]) : collect();
} else {
$users = $this->userService->searchByKeyword($validated['keyword']);
}
return $this->successWithResource(
'user.search_success',
UserResource::collection($users)
);
} catch (Exception $e) {
return $this->error('user.search_failed', 500, $e->getMessage());
}
}
/**
* 이메일 주소의 중복 여부를 확인합니다.
*
* @param CheckEmailRequest $request 이메일 중복 확인 요청 데이터
* @return JsonResponse 이메일 사용 가능 여부를 포함한 JSON 응답
*/
public function checkEmail(CheckEmailRequest $request): JsonResponse
{
try {
$validated = $request->validated();
$email = $validated['email'];
$excludeUserId = $validated['exclude_user_id'] ?? null;
$isAvailable = $this->userService->isEmailAvailable($email, $excludeUserId);
return $this->success(
$isAvailable ? 'user.email_available' : 'user.email_unavailable',
['available' => $isAvailable]
);
} catch (Exception $e) {
return $this->error('user.email_check_failed', 500, $e->getMessage());
}
}
/**
* 현재 로그인된 사용자의 언어 설정을 업데이트합니다.
*/
public function updateMyLanguage(UpdateLanguageRequest $request): JsonResponse
{
try {
$user = $request->user();
$validated = $request->validated();
$oldLanguage = $user->language;
$updatedUser = $this->userService->updateUserLanguage($user, $validated['language']);
return $this->successWithResource(
'user.language_update_success',
new UserResource($updatedUser)
);
} catch (ValidationException $e) {
return $this->error('user.language_update_failed', 422, $e->errors());
} catch (Exception $e) {
return $this->error('user.language_update_failed', 500, $e->getMessage());
}
}
/**
* 여러 사용자의 상태를 일괄 변경합니다.
*/
public function bulkUpdateStatus(BulkUpdateUserStatusRequest $request): JsonResponse
{
try {
$validated = $request->validated();
$result = $this->userService->bulkUpdateStatus($validated['ids'], $validated['status']);
return $this->success(
'user.bulk_status_updated',
$result
);
} catch (ValidationException $e) {
return $this->error('user.bulk_update_status_failed', 422, $e->errors());
} catch (Exception $e) {
return $this->error('user.bulk_update_status_failed', 500, $e->getMessage());
}
}
}
@@ -0,0 +1,207 @@
<?php
namespace App\Http\Controllers\Api\Auth;
use App\Http\Controllers\Api\Base\AuthBaseController;
use App\Http\Requests\Auth\ForgotPasswordRequest;
use App\Http\Requests\Auth\LoginRequest;
use App\Http\Requests\Auth\RegisterRequest;
use App\Http\Requests\Auth\ResetPasswordRequest;
use App\Http\Requests\Auth\ValidateResetTokenRequest;
use App\Http\Resources\UserResource;
use App\Services\AuthService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class AuthController extends AuthBaseController
{
public function __construct(
private AuthService $authService
) {
// 부모 생성자 호출하지 않음 - 인증 미들웨어를 수동으로 설정
// parent::__construct();
// 공개 인증 엔드포인트를 제외한 나머지에만 인증 미들웨어 적용
$this->middleware('auth:sanctum')->except([
'login',
'register',
'forgotPassword',
'resetPassword',
'validateResetToken',
]);
}
/**
* 사용자를 로그인시킵니다.
*
* @param LoginRequest $request 로그인 요청 데이터
* @return JsonResponse 로그인 결과와 사용자 정보, 토큰을 포함한 JSON 응답
*/
public function login(LoginRequest $request): JsonResponse
{
try {
$data = $this->authService->login(
$request->validated()['email'],
$request->validated()['password']
);
// 사용자 정보는 Resource로, 토큰은 그대로
$data['user'] = new UserResource($data['user']);
return $this->success('auth.login_success', $data);
} catch (ValidationException $e) {
return $this->unauthorized('auth.login_failed');
}
}
/**
* 새로운 사용자를 등록시킵니다.
*
* @param RegisterRequest $request 등록 요청 데이터
* @return JsonResponse 등록 결과와 사용자 정보, 토큰을 포함한 JSON 응답
*/
public function register(RegisterRequest $request): JsonResponse
{
try {
$data = $this->authService->register($request->validated());
// 사용자 정보는 Resource로, 토큰은 그대로
$data['user'] = new UserResource($data['user']);
return $this->success('auth.register_success', $data, 201);
} catch (ValidationException $e) {
return $this->validationError($e->errors(), 'auth.register_failed');
}
}
/**
* 사용자를 로그아웃시킵니다. (현재 디바이스만)
*
* @param Request $request HTTP 요청
* @return JsonResponse 로그아웃 성공 메시지
*/
public function logout(Request $request): JsonResponse
{
$this->authService->logout($request->user());
return $this->success('auth.logout_success');
}
/**
* 모든 디바이스에서 사용자를 로그아웃시킵니다.
*
* @param Request $request HTTP 요청
* @return JsonResponse 로그아웃 성공 메시지
*/
public function logoutFromAllDevices(Request $request): JsonResponse
{
$this->authService->logoutFromAllDevices($request->user());
return $this->success('auth.logout_all_devices_success');
}
/**
* 현재 로그인된 사용자의 정보를 반환합니다.
*
* @param Request $request HTTP 요청
* @return JsonResponse 사용자 정보를 포함한 JSON 응답
*/
public function user(Request $request): JsonResponse
{
$user = $request->user();
// 역할 관계 로드 (권한은 역할을 통해 간접 연결)
$user->load(['roles.permissions']);
return $this->successWithResource(
'common.success',
new UserResource($user)
);
}
/**
* 사용자의 인증 토큰을 갱신합니다.
*
* @param Request $request HTTP 요청
* @return JsonResponse 새로운 토큰과 사용자 정보를 포함한 JSON 응답
*/
public function refresh(Request $request): JsonResponse
{
$data = $this->authService->refreshToken($request->user());
// 사용자 정보는 Resource로, 토큰은 그대로
if (isset($data['user'])) {
$data['user'] = new UserResource($data['user']);
}
return $this->success('common.success', $data);
}
/**
* 비밀번호 재설정 토큰을 검증합니다.
*
* @param ValidateResetTokenRequest $request 토큰 검증 요청 데이터
* @return JsonResponse 토큰 유효성 검증 결과 JSON 응답
*/
public function validateResetToken(ValidateResetTokenRequest $request): JsonResponse
{
$validated = $request->validated();
$result = $this->authService->validateResetToken(
$validated['token'],
$validated['email']
);
if (! $result['valid']) {
return $this->validationError(
['token' => [$result['error']]],
'auth.reset_token_invalid'
);
}
return $this->success('common.success', $result);
}
/**
* 비밀번호 찾기 요청을 처리하고 인증 이메일을 발송합니다.
*
* @param ForgotPasswordRequest $request 비밀번호 찾기 요청 데이터
* @return JsonResponse 이메일 발송 결과 JSON 응답
*/
public function forgotPassword(ForgotPasswordRequest $request): JsonResponse
{
try {
$validated = $request->validated();
$this->authService->forgotPassword(
$validated['email'],
$validated['redirect_prefix'] ?? null
);
return $this->success('auth.password_reset_email_sent');
} catch (ValidationException $e) {
return $this->validationError($e->errors(), 'auth.password_reset_failed');
}
}
/**
* 비밀번호를 재설정합니다.
*
* @param ResetPasswordRequest $request 비밀번호 재설정 요청 데이터
* @return JsonResponse 비밀번호 재설정 결과 JSON 응답
*/
public function resetPassword(ResetPasswordRequest $request): JsonResponse
{
try {
$validated = $request->validated();
$this->authService->resetPassword(
$validated['token'],
$validated['email'],
$validated['password']
);
return $this->success('auth.password_reset_success');
} catch (ValidationException $e) {
return $this->validationError($e->errors(), 'auth.password_reset_failed');
}
}
}
@@ -0,0 +1,314 @@
<?php
namespace App\Http\Controllers\Api\Auth;
use App\Enums\AttachmentSourceType;
use App\Http\Controllers\Api\Base\AuthBaseController;
use App\Http\Requests\Auth\VerifyPasswordRequest;
use App\Http\Requests\User\ChangePasswordRequest;
use App\Http\Requests\User\UpdateProfileRequest;
use App\Http\Requests\User\UploadAvatarRequest;
use App\Http\Resources\UserResource;
use App\Models\User;
use App\Services\AttachmentService;
use App\Services\UserService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
/**
* 사용자 프로필 관리 컨트롤러
*
* 일반 사용자가 자신의 프로필을 관리할 수 있는 기능을 제공합니다.
*/
class ProfileController extends AuthBaseController
{
public function __construct(
private UserService $userService,
private AttachmentService $attachmentService
) {
parent::__construct();
}
/**
* 현재 로그인한 사용자의 프로필 정보를 조회합니다.
*
* @return JsonResponse 사용자 프로필 정보를 포함한 JSON 응답
*/
public function show(): JsonResponse
{
try {
$user = $this->getCurrentUser();
if (! $user) {
return $this->unauthorized('auth.unauthenticated');
}
$this->logUserActivity('profile.show');
return $this->success(
'user.profile_success',
(new UserResource($user))->toProfileArray()
);
} catch (\Exception $e) {
return $this->error('user.profile_failed', 500, null, ['error' => $e->getMessage()]);
}
}
/**
* 사용자 프로필을 업데이트합니다.
*
* @param UpdateProfileRequest $request 프로필 업데이트 요청 데이터
* @return JsonResponse 업데이트된 사용자 정보를 포함한 JSON 응답
*/
public function update(UpdateProfileRequest $request): JsonResponse
{
try {
$user = $this->getCurrentUser();
if (! $user) {
return $this->unauthorized('auth.unauthenticated');
}
$updatedUser = $this->userService->updateUser($user, $request->validated());
$this->logUserActivity('profile.update', $request->validated());
return $this->successWithResource(
'user.update_success',
new UserResource($updatedUser)
);
} catch (ValidationException $e) {
return $this->validationError($e->errors(), 'user.update_failed');
} catch (\Exception $e) {
return $this->error('user.update_failed', 500, $e->getMessage());
}
}
/**
* 사용자의 언어 설정을 변경합니다.
*
* @return JsonResponse 언어 변경 결과와 사용자 정보를 포함한 JSON 응답
*/
public function updateLanguage(): JsonResponse
{
try {
$user = $this->getCurrentUser();
$language = request('language');
if (! $user) {
return $this->unauthorized('auth.unauthenticated');
}
if (! in_array($language, ['ko', 'en'])) {
return $this->error('user.invalid_language', 400);
}
$updatedUser = $this->userService->updateUser($user, ['language' => $language]);
$this->logUserActivity('profile.update_language', ['language' => $language]);
return $this->successWithResource(
'user.language_update_success',
new UserResource($updatedUser)
);
} catch (\Exception $e) {
return $this->error('user.language_update_failed', 500, $e->getMessage());
}
}
/**
* 사용자의 활동 기록을 조회합니다.
*
* @return JsonResponse 사용자 활동 기록을 포함한 JSON 응답
*/
public function activityLog(): JsonResponse
{
try {
$user = $this->getCurrentUser();
if (! $user) {
return $this->unauthorized('auth.unauthenticated');
}
// TODO: 사용자 활동 로그 서비스 구현
$activities = []; // $this->userService->getUserActivities($user->id);
$this->logUserActivity('profile.activity_log');
return $this->success(
'user.activity_log_success',
['activities' => $activities]
);
} catch (\Exception $e) {
return $this->error('user.activity_log_failed', 500, null, ['error' => $e->getMessage()]);
}
}
/**
* 사용자 아바타를 업로드합니다.
*
* attachments 테이블의 다형성 관계를 사용하여 아바타를 저장합니다.
*
* @param UploadAvatarRequest $request 아바타 업로드 요청 데이터
* @return JsonResponse 업로드 결과를 포함한 JSON 응답
*/
public function uploadAvatar(UploadAvatarRequest $request): JsonResponse
{
try {
$user = $this->getCurrentUser();
if (! $user) {
return $this->unauthorized('auth.unauthenticated');
}
// 기존 아바타 Attachment 삭제
$existingAttachment = $user->avatarAttachment;
if ($existingAttachment) {
$this->attachmentService->delete($existingAttachment->id);
}
// 새 Attachment 생성 (다형성 관계)
$attachment = $this->attachmentService->upload(
$request->file('avatar'),
User::class,
$user->id,
'avatar',
AttachmentSourceType::Core
);
$this->logUserActivity('profile.avatar.upload', [
'attachment_id' => $attachment->id,
]);
// 관계 새로고침
$user->load('avatarAttachment');
return $this->success(
'user.avatar_upload_success',
[
'avatar' => $user->getAvatarUrl(),
'attachment_id' => $attachment->id,
]
);
} catch (\Exception $e) {
return $this->error('user.avatar_upload_failed', 500, $e->getMessage());
}
}
/**
* 사용자 아바타를 삭제합니다.
*
* attachments 테이블의 다형성 관계를 사용하여 아바타를 삭제합니다.
*
* @return JsonResponse 삭제 결과를 포함한 JSON 응답
*/
public function deleteAvatar(): JsonResponse
{
try {
$user = $this->getCurrentUser();
if (! $user) {
return $this->unauthorized('auth.unauthenticated');
}
// 아바타 Attachment 확인
$attachment = $user->avatarAttachment;
if (! $attachment) {
return $this->error('user.avatar_not_found', 404);
}
// Attachment 삭제 (파일도 함께 삭제됨)
$this->attachmentService->delete($attachment->id);
$this->logUserActivity('profile.avatar.delete');
return $this->success('user.avatar_delete_success');
} catch (\Exception $e) {
return $this->error('user.avatar_delete_failed', 500, null, ['error' => $e->getMessage()]);
}
}
/**
* 사용자 비밀번호를 확인합니다.
*
* @param Request $request 비밀번호 확인 요청 데이터
* @return JsonResponse 확인 결과를 포함한 JSON 응답
*/
public function verifyPassword(VerifyPasswordRequest $request): JsonResponse
{
try {
$user = $this->getCurrentUser();
if (! $user) {
return $this->unauthorized('auth.unauthenticated');
}
if (! Hash::check($request->password, $user->password)) {
return $this->error('user.password_incorrect', 401);
}
$this->logUserActivity('profile.verify_password');
return $this->success('user.password_verified');
} catch (ValidationException $e) {
return $this->validationError($e->errors(), 'user.password_verify_failed');
} catch (\Exception $e) {
return $this->error('user.password_verify_failed', 500, null, ['error' => $e->getMessage()]);
}
}
/**
* 사용자 비밀번호를 변경합니다.
*
* @param ChangePasswordRequest $request 비밀번호 변경 요청 데이터
* @return JsonResponse 비밀번호 변경 결과를 포함한 JSON 응답
*/
public function changePassword(ChangePasswordRequest $request): JsonResponse
{
try {
$user = $this->getCurrentUser();
if (! $user) {
return $this->unauthorized('auth.unauthenticated');
}
// 새 비밀번호로 업데이트 (UserService에서 해싱 처리)
$this->userService->updateUser($user, [
'password' => $request->password,
]);
$this->logUserActivity('profile.change_password');
return $this->success('user.password_change_success');
} catch (ValidationException $e) {
return $this->validationError($e->errors(), 'user.password_change_failed');
} catch (\Exception $e) {
return $this->error('user.password_change_failed', 500, null, ['error' => $e->getMessage()]);
}
}
/**
* 사용자 계정을 탈퇴 처리합니다.
*
* @return JsonResponse 탈퇴 처리 결과를 포함한 JSON 응답
*/
public function destroy(): JsonResponse
{
try {
$user = $this->getCurrentUser();
if (! $user) {
return $this->unauthorized('auth.unauthenticated');
}
$this->logUserActivity('profile.withdraw');
// 사용자 탈퇴 처리 (아바타, 토큰 삭제 및 익명화)
$this->userService->withdrawUser($user);
return $this->success('user.withdraw_success');
} catch (\Exception $e) {
return $this->error('user.withdraw_failed', 500, null, ['error' => $e->getMessage()]);
}
}
}
@@ -0,0 +1,42 @@
<?php
namespace App\Http\Controllers\Api\Base;
/**
* 관리자용 베이스 컨트롤러
*
* 모든 관리자 컨트롤러가 상속받아야 하는 기본 클래스입니다.
* 관리자 권한 검증, 관리자 전용 기능을 제공합니다.
*/
abstract class AdminBaseController extends BaseApiController
{
public function __construct()
{
// 관리자 인증 및 권한 체크 미들웨어 적용
$this->middleware(['auth:sanctum', 'admin'])->except(['login']);
}
/**
* 현재 관리자 사용자를 반환합니다.
*
* @return \App\Models\User|null
*/
protected function getCurrentAdmin()
{
return $this->getCurrentUser();
}
/**
* 관리자 권한을 확인합니다.
*
* @return bool
*/
protected function isAdmin(): bool
{
$user = $this->getCurrentAdmin();
// TODO: 실제 관리자 권한 체크 로직 구현
return $user !== null;
}
}
@@ -0,0 +1,99 @@
<?php
namespace App\Http\Controllers\Api\Base;
use Illuminate\Support\Facades\Log;
/**
* 인증된 사용자용 베이스 컨트롤러
*
* 인증이 필요한 API 컨트롤러가 상속받아야 하는 기본 클래스입니다.
* 사용자 인증, 권한 체크, 리소스 소유권 확인 등의 기능을 제공합니다.
*/
abstract class AuthBaseController extends BaseApiController
{
public function __construct()
{
// 사용자 인증 미들웨어 적용
$this->middleware('auth:sanctum');
}
/**
* 사용자 소유권을 확인합니다.
*
* @param int $userId 확인할 사용자 ID
* @return bool
*/
protected function isOwner(int $userId): bool
{
return $this->getCurrentUser()?->id === $userId;
}
/**
* 사용자가 특정 리소스에 접근할 수 있는지 확인합니다.
*
* @param mixed $resource 접근하려는 리소스
* @return bool
*/
protected function canAccessResource($resource): bool
{
// 기본적으로 자신의 리소스만 접근 가능
if (method_exists($resource, 'user_id')) {
return $this->getCurrentUser()?->id === $resource->user_id;
}
return true;
}
/**
* 사용자 활동을 기록합니다.
*
* @param string $action 수행한 작업
* @param array $data 관련 데이터
* @return void
*/
protected function logUserActivity(string $action, array $data = []): void
{
// TODO: 사용자 활동 로그 시스템 구현
Log::info("User Activity: {$action}", [
'user_id' => $this->getCurrentUser()?->uuid,
'data' => $data,
'timestamp' => now()
]);
}
/**
* 리소스 소유권을 확인하고, 소유자가 아니면 Forbidden 응답을 반환합니다.
*
* @param mixed $resource 확인할 리소스
* @param string $messageKey 오류 메시지 키
* @return \Illuminate\Http\JsonResponse|null 소유자이면 null, 아니면 Forbidden 응답
*/
protected function checkOwnership($resource, string $messageKey = 'common.forbidden')
{
if (!$this->canAccessResource($resource)) {
return $this->forbidden($messageKey);
}
return null;
}
/**
* API 사용량을 기록합니다.
*
* @param string $endpoint 엔드포인트
* @param array $data 관련 데이터
* @return void
*/
protected function logApiUsage(string $endpoint, array $data = []): void
{
// TODO: API 사용량 통계 시스템 구현
Log::info("Auth API Usage: {$endpoint}", [
'user_id' => $this->getCurrentUser()?->uuid,
'ip' => request()->ip(),
'user_agent' => request()->userAgent(),
'data' => $data,
'timestamp' => now(),
]);
}
}
@@ -0,0 +1,264 @@
<?php
namespace App\Http\Controllers\Api\Base;
use App\Helpers\ResponseHelper;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
/**
* API 컨트롤러의 최상위 베이스 클래스
*
* 모든 API 컨트롤러가 공통으로 사용하는 기능을 제공합니다.
* Admin, Auth, Public 컨트롤러는 이 클래스를 상속받습니다.
*/
abstract class BaseApiController extends Controller
{
/**
* 성공 응답을 생성합니다.
*
* @param string $messageKey 메시지 키
* @param mixed $data 응답 데이터
* @param int $statusCode HTTP 상태 코드
* @param array $messageParams 메시지 매개변수
* @return \Illuminate\Http\JsonResponse
*/
protected function success(
string $messageKey = 'common.success',
mixed $data = null,
int $statusCode = 200,
array $messageParams = []
) {
return ResponseHelper::success($messageKey, $data, $statusCode, $messageParams, 'core');
}
/**
* 실패 응답을 생성합니다.
*
* @param string $messageKey 메시지 키
* @param int $statusCode HTTP 상태 코드
* @param mixed $errors 오류 정보
* @param array $messageParams 메시지 매개변수
* @return \Illuminate\Http\JsonResponse
*/
protected function error(
string $messageKey = 'common.failed',
int $statusCode = 400,
mixed $errors = null,
array $messageParams = []
) {
return ResponseHelper::error($messageKey, $statusCode, $errors, $messageParams, 'core');
}
/**
* 리소스와 함께 성공 응답을 생성합니다.
*
* @param string $messageKey 메시지 키
* @param mixed $resource JSON 리소스
* @param int $statusCode HTTP 상태 코드
* @param array $messageParams 메시지 매개변수
* @return \Illuminate\Http\JsonResponse
*/
protected function successWithResource(
string $messageKey = 'common.success',
mixed $resource = null,
int $statusCode = 200,
array $messageParams = []
) {
return ResponseHelper::successWithResource($messageKey, $resource, $statusCode, $messageParams, 'core');
}
/**
* 현재 인증된 사용자를 반환합니다.
*
* @return \App\Models\User|null
*/
protected function getCurrentUser()
{
return Auth::user();
}
/**
* Not Found 응답을 생성합니다.
*
* @param string $messageKey 메시지 키
* @param array $messageParams 메시지 매개변수
* @return \Illuminate\Http\JsonResponse
*/
protected function notFound(
string $messageKey = 'common.not_found',
array $messageParams = []
) {
return $this->error($messageKey, 404, null, $messageParams);
}
/**
* Unauthorized 응답을 생성합니다.
*
* @param string $messageKey 메시지 키
* @param array $messageParams 메시지 매개변수
* @return \Illuminate\Http\JsonResponse
*/
protected function unauthorized(
string $messageKey = 'common.unauthorized',
array $messageParams = []
) {
return $this->error($messageKey, 401, null, $messageParams);
}
/**
* Forbidden 응답을 생성합니다.
*
* @param string $messageKey 메시지 키
* @param array $messageParams 메시지 매개변수
* @return \Illuminate\Http\JsonResponse
*/
protected function forbidden(
string $messageKey = 'common.forbidden',
array $messageParams = []
) {
return $this->error($messageKey, 403, null, $messageParams);
}
/**
* Validation Error 응답을 생성합니다.
*
* @param mixed $errors 검증 오류
* @param string $messageKey 메시지 키
* @param array $messageParams 메시지 매개변수
* @return \Illuminate\Http\JsonResponse
*/
protected function validationError(
mixed $errors,
string $messageKey = 'common.validation_failed',
array $messageParams = []
) {
return $this->error($messageKey, 422, $errors, $messageParams);
}
/**
* 파일 응답을 반환합니다 (ETag 및 캐싱 헤더 포함).
*
* @param string $filePath 파일 경로
* @param string $mimeType MIME 타입
* @param int $maxAge 캐시 유지 시간 (초, 기본: 1년)
*/
protected function fileResponse(string $filePath, string $mimeType, int $maxAge = 31536000): BinaryFileResponse|Response
{
// ETag 생성 (파일 수정 시간 + 파일 크기 기반)
$etag = md5(filemtime($filePath).filesize($filePath));
// If-None-Match 헤더 확인 (ETag 비교)
if (request()->header('If-None-Match') === $etag) {
return response('', 304)->header('ETag', $etag); // 304 Not Modified
}
// 환경별 캐싱 정책
$cacheControl = app()->environment('production')
? "public, max-age={$maxAge}, immutable" // 프로덕션: immutable 추가
: 'no-cache'; // 개발: 캐싱 비활성화
$response = response()->file($filePath, [
'Content-Type' => $mimeType,
'Expires' => gmdate('D, d M Y H:i:s', time() + $maxAge).' GMT',
'ETag' => $etag,
]);
// Cache-Control 헤더를 수동으로 설정 (기본 헤더 덮어쓰기)
$response->headers->set('Cache-Control', $cacheControl);
return $response;
}
/**
* JSON 응답을 반환합니다 (캐싱 헤더 포함).
*
* @param mixed $data JSON으로 변환할 데이터
* @param int $maxAge 캐시 유지 시간 (초, 기본: 1시간)
* @param int $status HTTP 상태 코드
* @return JsonResponse JSON 응답
*/
protected function cachedJsonResponse(mixed $data, int $maxAge = 3600, int $status = 200): JsonResponse
{
return response()->json($data, $status, [
'Cache-Control' => "public, max-age={$maxAge}",
]);
}
/**
* ETag를 생성합니다.
*
* @param mixed $data 해시할 데이터
* @return string ETag 값 (따옴표 포함)
*/
protected function generateETag(mixed $data): string
{
$content = is_string($data) ? $data : json_encode($data);
return '"' . md5($content) . '"';
}
/**
* 클라이언트 캐시가 유효한지 확인합니다.
*
* @param string $etag 현재 ETag 값
* @return bool 캐시가 유효하면 true
*/
protected function isNotModified(string $etag): bool
{
$clientEtag = request()->header('If-None-Match');
return $clientEtag !== null && $clientEtag === $etag;
}
/**
* 304 Not Modified 응답을 반환합니다.
*
* @param string $etag ETag 값
* @param int $maxAge 캐시 TTL (초)
* @return Response 304 응답
*/
protected function notModifiedResponse(string $etag, int $maxAge = 3600): Response
{
return response('', 304)
->header('ETag', $etag)
->header('Cache-Control', "public, max-age={$maxAge}");
}
/**
* 캐시 헤더와 함께 성공 응답을 반환합니다.
*
* 클라이언트의 ETag가 일치하면 304 Not Modified를 반환합니다.
*
* @param string $messageKey 메시지 키
* @param mixed $data 응답 데이터
* @param int $maxAge 캐시 TTL (초, 기본: 1시간)
* @param array $messageParams 메시지 매개변수
* @return JsonResponse|Response JSON 응답 또는 304 응답
*/
protected function successWithCache(
string $messageKey = 'common.success',
mixed $data = null,
int $maxAge = 3600,
array $messageParams = []
): JsonResponse|Response {
$etag = $this->generateETag($data);
// 304 Not Modified 처리
if ($this->isNotModified($etag)) {
return $this->notModifiedResponse($etag, $maxAge);
}
return response()->json([
'success' => true,
'message' => __($messageKey, $messageParams),
'data' => $data,
])
->header('ETag', $etag)
->header('Cache-Control', "public, max-age={$maxAge}")
->header('Vary', 'Accept-Encoding, Accept-Language');
}
}
@@ -0,0 +1,68 @@
<?php
namespace App\Http\Controllers\Api\Base;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
/**
* 공개 API용 베이스 컨트롤러
*
* 인증이 필요하지 않은 공개 API 컨트롤러가 상속받아야 하는 기본 클래스입니다.
* 캐싱, 속도 제한, API 사용량 추적 등의 기능을 제공합니다.
*/
abstract class PublicBaseController extends BaseApiController
{
public function __construct()
{
// 공개 API는 속도 제한 없음 (무제한 접근 허용)
}
/**
* 캐시된 응답을 반환하거나 새로 생성합니다.
*
* @param string $key 캐시 키
* @param callable $callback 데이터 생성 콜백
* @param int $ttl 캐시 유지 시간 (초)
* @return mixed
*/
protected function cached(string $key, callable $callback, int $ttl = 3600)
{
return cache()->remember($key, $ttl, $callback);
}
/**
* API 사용량을 기록합니다.
*
* @param string $endpoint 엔드포인트
* @param array $data 관련 데이터
* @return void
*/
protected function logApiUsage(string $endpoint, array $data = []): void
{
// TODO: API 사용량 통계 시스템 구현
Log::info("Public API Usage: {$endpoint}", [
'ip' => request()->ip(),
'user_agent' => request()->userAgent(),
'data' => $data,
'timestamp' => now()
]);
}
/**
* 클라이언트 정보를 가져옵니다.
*
* @return array
*/
protected function getClientInfo(): array
{
return [
'ip' => request()->ip(),
'user_agent' => request()->userAgent(),
'referer' => request()->header('referer'),
'timestamp' => now()
];
}
}
@@ -0,0 +1,46 @@
<?php
namespace App\Http\Controllers\Api\Public;
use App\Http\Controllers\Api\Base\PublicBaseController;
use App\Services\LayoutPreviewService;
use Illuminate\Http\JsonResponse;
/**
* 공개 레이아웃 미리보기 API 컨트롤러
*
* 토큰 기반으로 미리보기 레이아웃 JSON을 서빙합니다.
* 인증 불필요 (토큰이 보안 메커니즘).
*/
class LayoutPreviewController extends PublicBaseController
{
/**
* LayoutPreviewService 주입
*
* @param LayoutPreviewService $layoutPreviewService 미리보기 서비스
*/
public function __construct(
private LayoutPreviewService $layoutPreviewService
) {
parent::__construct();
}
/**
* 미리보기 레이아웃 JSON 서빙
*
* 토큰으로 편집 중인 레이아웃을 조회하고, 상속 병합 및 extension 적용 후 반환합니다.
*
* @param string $token 미리보기 토큰 (UUID)
* @return JsonResponse
*/
public function serve(string $token): JsonResponse
{
$layout = $this->layoutPreviewService->getPreviewLayout($token);
if (! $layout) {
return $this->notFound(__('templates.layout_not_found'));
}
return response()->json($layout);
}
}
@@ -0,0 +1,82 @@
<?php
namespace App\Http\Controllers\Api\Public;
use App\Http\Controllers\Api\Base\PublicBaseController;
use App\Services\AttachmentService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* 첨부파일 다운로드 컨트롤러
*
* 권한 정책에 따라 로그인/비로그인 사용자 모두 접근 가능합니다.
* 권한 체크는 AttachmentService에서 하이브리드 방식으로 처리합니다.
*/
class PublicAttachmentController extends PublicBaseController
{
/**
* PublicAttachmentController 생성자
*
* @param AttachmentService $attachmentService 첨부파일 서비스
*/
public function __construct(
private AttachmentService $attachmentService
) {
parent::__construct();
}
/**
* 첨부파일 다운로드
*
* 이미지 파일은 캐싱 헤더와 함께 인라인 표시하고,
* 그 외 파일은 다운로드 방식으로 제공합니다.
*
* @param Request $request HTTP 요청
* @param string $hash 첨부파일 해시 (12자)
* @return BinaryFileResponse|StreamedResponse|Response|JsonResponse 파일 응답 또는 에러 응답
*/
public function download(Request $request, string $hash): BinaryFileResponse|StreamedResponse|Response|JsonResponse
{
$user = $request->user();
try {
// 파일 정보 조회 (권한 체크 포함)
$fileInfo = $this->attachmentService->getFileInfo($hash, $user);
if (!$fileInfo) {
$attachment = $this->attachmentService->findByHash($hash);
if (!$attachment) {
return $this->notFound('attachment.not_found');
}
return $this->forbidden('attachment.access_denied');
}
// 이미지 파일은 캐싱 헤더와 함께 응답 (환경설정 레이아웃 캐시 TTL 사용, 기본 24시간)
if (str_starts_with($fileInfo['mime_type'], 'image/')) {
return $this->fileResponse(
$fileInfo['path'],
$fileInfo['mime_type'],
(int) g7_core_settings('cache.layout_ttl', 86400)
);
}
// 이미지가 아닌 파일은 기존 다운로드 방식 유지
$response = $this->attachmentService->download($hash, $user);
if (!$response) {
return $this->forbidden('attachment.access_denied');
}
return $response;
} catch (AuthorizationException) {
return $this->forbidden('attachment.access_denied');
}
}
}
@@ -0,0 +1,154 @@
<?php
namespace App\Http\Controllers\Api\Public;
use App\Enums\ExtensionStatus;
use App\Helpers\PermissionHelper;
use App\Http\Controllers\Api\Base\PublicBaseController;
use App\Services\LayoutService;
use App\Services\TemplateService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
/**
* 공개 레이아웃 API 컨트롤러
*
* 템플릿 레이아웃 JSON을 프론트엔드에 제공합니다.
*/
class PublicLayoutController extends PublicBaseController
{
/**
* 레이아웃 캐시 TTL (초)
*/
private const CACHE_TTL = 3600;
/**
* TemplateService 및 LayoutService 주입
*/
public function __construct(
private TemplateService $templateService,
private LayoutService $layoutService
) {
parent::__construct();
}
/**
* 병합된 레이아웃 JSON 서빙
*
* HTTP 캐시 헤더 및 ETag를 지원하여 전송 효율성을 높입니다.
* 사용자 권한에 따라 컴포넌트를 필터링하여 서빙합니다.
*
* @param string $templateIdentifier 템플릿 식별자
* @param string $layoutName 레이아웃 이름
* @return JsonResponse|Response JSON 응답 또는 304 Not Modified
*/
public function serve(string $templateIdentifier, string $layoutName): JsonResponse|Response
{
// API 사용량 기록
$this->logApiUsage("layouts/{$templateIdentifier}/{$layoutName}", [
'identifier' => $templateIdentifier,
'layout_name' => $layoutName,
]);
// 1. 템플릿 조회 (활성화 여부 확인)
$template = $this->templateService->findByIdentifier($templateIdentifier);
// 템플릿이 존재하지 않거나 활성화되지 않은 경우
if (! $template || $template->status !== ExtensionStatus::Active->value) {
return $this->notFound(__('templates.layout_not_found'));
}
try {
// 캐시 버전을 키에 포함하여 모듈/플러그인 변경 시 캐시 무효화
$cacheVersion = request()->query('v', 0);
// 서버 측 캐싱 (1시간 유효)
// getLayout()을 사용하여 레이아웃 로드, 병합, 확장 적용을 한 번에 수행
$mergedLayout = $this->cached(
"layout.{$templateIdentifier}.{$layoutName}.v{$cacheVersion}",
fn () => $this->layoutService->getLayout($templateIdentifier, $layoutName),
self::CACHE_TTL
);
// 권한 체크 (permissions 필드가 있는 경우)
$permissionCheckResult = $this->checkLayoutPermissions($mergedLayout);
if ($permissionCheckResult !== null) {
return $permissionCheckResult;
}
// 컴포넌트별 권한 필터링 (post-cache, 사용자별 동적 처리)
$mergedLayout = $this->layoutService->filterComponentsByPermissions(
$mergedLayout,
request()->user()
);
// ETag 및 Cache-Control 헤더와 함께 응답 반환
return $this->successWithCache(
'templates.messages.layout_served',
$mergedLayout,
self::CACHE_TTL
);
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
// 레이아웃 또는 부모 레이아웃을 찾을 수 없음 - 예외 메시지 전달
return $this->notFound($e->getMessage());
}
}
/**
* 레이아웃 권한 체크
*
* 레이아웃에 permissions 필드가 있으면 권한을 체크합니다.
* flat array(AND), 구조화 객체(OR/AND 중첩) 모두 지원합니다.
*
* @param array $layout 병합된 레이아웃 데이터
* @return JsonResponse|null 권한 없으면 에러 응답, 있으면 null
*/
private function checkLayoutPermissions(array $layout): ?JsonResponse
{
$permissions = $layout['permissions'] ?? [];
// 권한 요구사항 없음 (공개 레이아웃)
if (empty($permissions)) {
return null;
}
// 구조화된 권한 로직(OR/AND) 지원
if (! PermissionHelper::checkWithLogic($permissions)) {
$user = request()->user();
$permissionList = $this->flattenPermissionList($permissions);
// 비회원이면 401, 회원이면 403
if ($user === null) {
return $this->unauthorized('auth.layout_guest_permission_denied', [
'required_permissions' => $permissionList,
]);
}
return $this->forbidden('auth.layout_permission_denied', [
'required_permissions' => $permissionList,
]);
}
return null;
}
/**
* 권한 구조에서 모든 권한 식별자를 평탄화하여 문자열로 반환합니다.
*
* 에러 메시지에 필요한 권한 목록을 표시하기 위해 사용합니다.
*
* @param array $permissions 권한 구조 (flat array 또는 구조화 객체)
* @return string 쉼표로 구분된 권한 식별자 문자열
*/
private function flattenPermissionList(array $permissions): string
{
$flat = [];
array_walk_recursive($permissions, function ($value) use (&$flat) {
if (is_string($value)) {
$flat[] = $value;
}
});
return implode(', ', array_unique($flat));
}
}
@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers\Api\Public;
use App\Http\Controllers\Api\Base\PublicBaseController;
use App\Http\Requests\Public\Module\ServeModuleAssetRequest;
use App\Services\ModuleService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
/**
* 공개 모듈 API 컨트롤러
*
* 모듈 에셋 서빙을 담당합니다.
*/
class PublicModuleController extends PublicBaseController
{
public function __construct(
private readonly ModuleService $moduleService
) {
parent::__construct();
}
/**
* 모듈 에셋 서빙
*
* @param ServeModuleAssetRequest $request 검증된 요청 (경로, 확장자 검증 완료)
* @param string $identifier 모듈 식별자 (vendor-module 형식)
* @param string $path 에셋 경로 (dist/js/module.iife.js 등)
* @return BinaryFileResponse|JsonResponse|Response
*/
public function serveAsset(
ServeModuleAssetRequest $request,
string $identifier,
string $path
): BinaryFileResponse|JsonResponse|Response {
// FormRequest에서 이미 보안 검증 완료
// API 사용량 기록
$this->logApiUsage('modules.assets', ['identifier' => $identifier, 'path' => $path]);
// Service에서 파일 경로 조회 (검증은 FormRequest에서 완료됨)
$result = $this->moduleService->getAssetFilePath($identifier, $path);
// 에러 처리
if (! $result['success']) {
return match ($result['error']) {
'module_not_found' => $this->notFound(__('modules.errors.not_found', ['module' => $identifier])),
'file_not_found' => $this->notFound(__('modules.errors.file_not_found')),
'file_type_not_allowed' => $this->forbidden(__('modules.errors.file_type_not_allowed')),
default => $this->error(__('modules.errors.unknown_error'), 500),
};
}
// 파일 반환 (ETag 및 환경별 캐싱 헤더 포함, 1년 캐시)
return $this->fileResponse($result['filePath'], $result['mimeType'], 31536000);
}
}
@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers\Api\Public;
use App\Http\Controllers\Api\Base\PublicBaseController;
use App\Http\Requests\Public\Plugin\ServePluginAssetRequest;
use App\Services\PluginService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
/**
* 공개 플러그인 API 컨트롤러
*
* 플러그인 에셋 서빙을 담당합니다.
*/
class PublicPluginController extends PublicBaseController
{
public function __construct(
private readonly PluginService $pluginService
) {
parent::__construct();
}
/**
* 플러그인 에셋 서빙
*
* @param ServePluginAssetRequest $request 검증된 요청 (경로, 확장자 검증 완료)
* @param string $identifier 플러그인 식별자 (vendor-plugin 형식)
* @param string $path 에셋 경로 (dist/js/plugin.iife.js 등)
* @return BinaryFileResponse|JsonResponse|Response
*/
public function serveAsset(
ServePluginAssetRequest $request,
string $identifier,
string $path
): BinaryFileResponse|JsonResponse|Response {
// FormRequest에서 이미 보안 검증 완료
// API 사용량 기록
$this->logApiUsage('plugins.assets', ['identifier' => $identifier, 'path' => $path]);
// Service에서 파일 경로 조회 (검증은 FormRequest에서 완료됨)
$result = $this->pluginService->getAssetFilePath($identifier, $path);
// 에러 처리
if (! $result['success']) {
return match ($result['error']) {
'plugin_not_found' => $this->notFound(__('plugins.errors.not_found', ['plugin' => $identifier])),
'file_not_found' => $this->notFound(__('plugins.errors.file_not_found')),
'file_type_not_allowed' => $this->forbidden(__('plugins.errors.file_type_not_allowed')),
default => $this->error(__('plugins.errors.unknown_error'), 500),
};
}
// 파일 반환 (ETag 및 환경별 캐싱 헤더 포함, 1년 캐시)
return $this->fileResponse($result['filePath'], $result['mimeType'], 31536000);
}
}
@@ -0,0 +1,52 @@
<?php
namespace App\Http\Controllers\Api\Public;
use App\Http\Controllers\Api\Base\PublicBaseController;
use App\Models\User;
use App\Services\UserService;
use Illuminate\Http\JsonResponse;
/**
* 공개 사용자 프로필 컨트롤러
*
* 타인의 공개 프로필 정보를 조회하는 API를 제공합니다.
* 인증 없이 접근 가능합니다.
*
* 게시글 관련 API는 게시판 모듈(sirsoft-board)에서 제공합니다:
* - 게시글 목록: GET /api/modules/sirsoft-board/users/{userId}/posts
* - 게시글 통계: GET /api/modules/sirsoft-board/users/{userId}/posts/stats
*/
class PublicProfileController extends PublicBaseController
{
public function __construct(
private UserService $userService
) {
parent::__construct();
}
/**
* 사용자의 공개 프로필을 조회합니다.
*
* 사용자 상태에 따라 차등 데이터를 반환합니다:
* - active: 전체 정보 (name, avatar, bio, created_at)
* - inactive: 기본 정보만 (bio 제외)
* - blocked: 최소 정보만 (avatar, bio, created_at 제외)
* - withdrawn/미존재: 404 에러
*
* 게시글 통계(posts_count, comments_count)는 게시판 모듈 API를 통해 별도 조회합니다.
*
* @param User $user 사용자 모델 (Route Model Binding, uuid 기반)
* @return JsonResponse 공개 프로필 정보
*/
public function show(User $user): JsonResponse
{
$profileData = $this->userService->getPublicProfile($user->id);
if ($profileData === null) {
return $this->error('user.not_found', 404);
}
return $this->success('user.profile_success', $profileData);
}
}
@@ -0,0 +1,130 @@
<?php
namespace App\Http\Controllers\Api\Public;
use App\Extension\HookManager;
use App\Http\Controllers\Api\Base\PublicBaseController;
use App\Http\Requests\Public\SearchRequest;
use Illuminate\Http\JsonResponse;
/**
* 통합 검색 API 컨트롤러
*
* 코어에서 검색 기반 구조를 제공하고,
* 각 모듈이 Filter Hook을 통해 검색 결과를 추가합니다.
*/
class PublicSearchController extends PublicBaseController
{
/**
* 통합 검색 수행
*
* @param SearchRequest $request HTTP 요청
* @return JsonResponse 검색 결과
*/
public function search(SearchRequest $request): JsonResponse
{
$validated = $request->validated();
$q = trim($validated['q'] ?? '');
$type = $validated['type'] ?? 'all';
$sort = $validated['sort'] ?? 'relevance';
$page = (int) ($validated['page'] ?? 1);
$perPage = (int) ($validated['per_page'] ?? 10);
// 검색어가 비어있으면 빈 결과 반환
if (empty($q)) {
return $this->success(__('search.empty_keyword'), $this->buildEmptyResponse());
}
// 검색 컨텍스트 구성 (모듈별 파라미터는 request 전체를 전달하여 각 모듈이 직접 처리)
$context = [
'q' => $q,
'type' => $type,
'sort' => $sort,
'page' => $page,
'per_page' => $perPage,
'all_tab_limit' => 5,
'user' => $request->user(),
'request' => $request,
];
// Filter Hook을 통해 각 모듈이 검색 결과 추가
// 모듈들은 자신의 카테고리를 ['category' => ['total' => N, 'items' => [...]]] 형식으로 추가
// 빈 배열로 시작하여 모듈이 자유롭게 카테고리 추가 가능
$results = HookManager::applyFilters('core.search.results', [], $context);
// 프론트엔드용 응답 구조로 변환 (훅을 통해 모듈이 처리)
$response = $this->buildResponse($q, $results, $context);
return $this->success(
__('search.results_found', ['count' => $response['total']]),
$response
);
}
/**
* 빈 검색 결과 응답 구조 생성
*
* @return array 빈 결과 응답
*/
private function buildEmptyResponse(): array
{
return [
'q' => '',
'total' => 0,
];
}
/**
* Hook 결과를 프론트엔드용 응답 구조로 변환
*
* 코어는 기본 응답 구조만 생성하고,
* 각 모듈이 core.search.build_response 훅을 통해 자신의 카테고리 응답을 구성합니다.
*
* @param string $q 검색어
* @param array $results Hook에서 반환된 검색 결과
* @param array $context 검색 컨텍스트
* @return array 프론트엔드용 응답 구조
*/
private function buildResponse(string $q, array $results, array $context): array
{
$response = [
'q' => $q,
'total' => $this->calculateTotal($results),
];
// 각 모듈이 자신의 카테고리 응답 구조를 추가할 수 있도록 훅 제공
// 모듈은 $results에서 자신의 데이터를 가져와 $response에 추가
$response = HookManager::applyFilters('core.search.build_response', $response, $results, $context);
// 전체 합계를 항상 보존 (탭 UI에서 전체 건수 표시용)
$response['all_count'] = $response['total'];
// 특정 탭 조회 시 total을 해당 탭의 count로 설정
$type = $context['type'] ?? 'all';
if ($type !== 'all') {
$countKey = $type.'_count';
if (isset($response[$countKey])) {
$response['total'] = $response[$countKey];
}
}
return $response;
}
/**
* 전체 검색 결과 수 계산
*
* @param array $results Hook에서 반환된 검색 결과
* @return int 전체 결과 수
*/
private function calculateTotal(array $results): int
{
$total = 0;
foreach ($results as $categoryData) {
$total += $categoryData['total'] ?? 0;
}
return $total;
}
}
@@ -0,0 +1,265 @@
<?php
namespace App\Http\Controllers\Api\Public;
use App\Enums\ExtensionStatus;
use App\Extension\Traits\ClearsTemplateCaches;
use App\Http\Controllers\Api\Base\PublicBaseController;
use App\Http\Requests\Public\Template\ServeTemplateAssetRequest;
use App\Services\TemplateService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
/**
* 공개 템플릿 API 컨트롤러
*/
class PublicTemplateController extends PublicBaseController
{
public function __construct(
private TemplateService $templateService
) {
parent::__construct();
}
/**
* 템플릿 라우트 정보 조회 (활성화된 모듈의 routes 포함)
*
* @param string $identifier 템플릿 식별자 (vendor-name 형식)
*/
public function getRoutes(string $identifier): JsonResponse
{
// API 사용량 기록
$this->logApiUsage('templates.routes', ['identifier' => $identifier]);
// 캐시 버전을 키에 포함하여 모듈/플러그인 변경 시 캐시 무효화
$cacheVersion = request()->query('v', 0);
// 캐싱된 응답 반환 (1시간 유효)
$routesData = $this->cached(
"template.routes.{$identifier}.v{$cacheVersion}",
function () use ($identifier) {
// Service에서 템플릿 + 모듈 routes 데이터 병합 조회
$result = $this->templateService->getRoutesDataWithModules($identifier);
// 에러 처리
if (! $result['success']) {
return ['error' => $result['error']];
}
return ['success' => true, 'data' => $result['data']];
},
3600
);
// 에러 응답 처리
if (isset($routesData['error'])) {
return match ($routesData['error']) {
'template_not_found' => $this->notFound(
__('templates.errors.not_found', ['template' => $identifier])
),
'routes_not_found' => $this->notFound(
__('templates.errors.routes_not_found')
),
'invalid_json' => $this->error(
__('templates.errors.invalid_json'),
500
),
default => $this->error(__('templates.errors.unknown_error'), 500),
};
}
// data 키 누락 시 기본 구조 반환 (캐시 오류 방어)
if (! isset($routesData['data'])) {
return $this->error(__('templates.errors.invalid_cache_data'), 500);
}
return $this->success(
__('templates.messages.routes_retrieved'),
$routesData['data']
);
}
/**
* 템플릿 정적 파일 서빙
*/
public function serveAsset(ServeTemplateAssetRequest $request, string $identifier, string $path): BinaryFileResponse|JsonResponse|Response
{
// FormRequest에서 이미 보안 검증 완료
// API 사용량 기록
$this->logApiUsage('templates.assets', ['identifier' => $identifier, 'path' => $path]);
// Service에서 파일 경로 조회 (검증은 FormRequest에서 완료됨)
$result = $this->templateService->getAssetFilePath($identifier, $path);
// 에러 처리
if (! $result['success']) {
return match ($result['error']) {
'template_not_found' => $this->notFound(__('templates.errors.not_found', ['template' => $identifier])),
'file_not_found' => $this->notFound(__('templates.errors.file_not_found')),
'file_type_not_allowed' => $this->forbidden(__('templates.errors.file_type_not_allowed')),
default => $this->error(__('templates.errors.unknown_error'), 500),
};
}
// 파일 반환 (ETag 및 환경별 캐싱 헤더 포함)
return $this->fileResponse($result['filePath'], $result['mimeType'], 31536000);
}
/**
* 컴포넌트 정의 파일 서빙
*
* @param string $identifier 템플릿 식별자
*/
public function serveComponents(string $identifier): JsonResponse
{
// API 사용량 기록
$this->logApiUsage('templates.components', ['identifier' => $identifier]);
// Service에서 파일 경로 조회 및 검증
$result = $this->templateService->getComponentsFilePath($identifier);
// 에러 처리
if (! $result['success']) {
return match ($result['error']) {
'template_not_found' => $this->notFound(__('templates.errors.not_found', ['template' => $identifier])),
'components_not_found' => $this->notFound(__('templates.errors.components_not_found')),
default => $this->error(__('templates.errors.unknown_error'), 500),
};
}
// JSON 파싱 및 반환
$components = json_decode(file_get_contents($result['componentsPath']), true);
return $this->cachedJsonResponse($components, 3600);
}
/**
* 템플릿 설정 파일 서빙 (template.json)
*
* error_config 등 템플릿 메타데이터를 프론트엔드에 제공합니다.
*
* @param string $identifier 템플릿 식별자
*/
public function serveConfig(string $identifier): JsonResponse
{
// API 사용량 기록
$this->logApiUsage('templates.config', ['identifier' => $identifier]);
// 캐싱된 응답 반환 (1시간 유효)
$configData = $this->cached(
"template.config.{$identifier}",
function () use ($identifier) {
// 템플릿 존재 확인
$template = $this->templateService->findByIdentifier($identifier);
if (! $template || $template->status !== ExtensionStatus::Active->value) {
return ['error' => 'template_not_found'];
}
// template.json 파일 경로
$configPath = base_path("templates/{$identifier}/template.json");
if (! file_exists($configPath)) {
return ['error' => 'config_not_found'];
}
$content = file_get_contents($configPath);
$data = json_decode($content, true);
if (json_last_error() !== JSON_ERROR_NONE) {
return ['error' => 'invalid_json'];
}
return ['success' => true, 'data' => $data];
},
3600
);
// 에러 응답 처리
if (isset($configData['error'])) {
return match ($configData['error']) {
'template_not_found' => $this->notFound(
__('templates.errors.not_found', ['template' => $identifier])
),
'config_not_found' => $this->notFound(
__('templates.errors.template_json_not_found')
),
'invalid_json' => $this->error(
__('templates.errors.invalid_json'),
500
),
default => $this->error(__('templates.errors.unknown_error'), 500),
};
}
// 캐시 버전을 응답에 포함하여 프론트엔드가 API 호출 시 사용하도록 함
$responseData = $configData['data'];
$responseData['cache_version'] = ClearsTemplateCaches::getExtensionCacheVersion();
return $this->success(
__('templates.messages.config_retrieved'),
$responseData
);
}
/**
* 템플릿 다국어 파일 서빙 (활성화된 모듈의 다국어 데이터 포함)
*
* @param string $identifier 템플릿 식별자
* @param string $locale 로케일 (ko, en 등)
*/
public function serveLanguage(string $identifier, string $locale): JsonResponse
{
// API 사용량 기록
$this->logApiUsage('templates.language', [
'identifier' => $identifier,
'locale' => $locale,
]);
// 캐시 버전을 키에 포함하여 모듈/플러그인 변경 시 캐시 무효화
$cacheVersion = request()->query('v', 0);
// 캐싱된 응답 반환 (1시간 유효)
$languageData = $this->cached(
"template.language.{$identifier}.{$locale}.v{$cacheVersion}",
function () use ($identifier, $locale) {
// Service에서 템플릿 + 모듈 다국어 데이터 병합 조회
$result = $this->templateService->getLanguageDataWithModules($identifier, $locale);
// 에러 처리
if (! $result['success']) {
return ['error' => $result['error']];
}
return ['success' => true, 'data' => $result['data']];
},
3600
);
// 에러 응답 처리
if (isset($languageData['error'])) {
return match ($languageData['error']) {
'template_not_found' => $this->notFound(
__('templates.errors.not_found', ['template' => $identifier])
),
'locale_not_supported' => $this->notFound(
__('templates.errors.locale_not_supported', [
'template' => $identifier,
'locale' => $locale,
])
),
'file_not_found' => $this->notFound(
__('templates.errors.language_file_not_found', ['locale' => $locale])
),
'invalid_json' => $this->error(
__('templates.errors.invalid_json'),
500
),
default => $this->error(__('templates.errors.unknown_error'), 500),
};
}
// 성공 응답 (JSON 데이터 직접 반환, 래핑 없음)
return $this->cachedJsonResponse($languageData['data'], 3600);
}
}
@@ -0,0 +1,44 @@
<?php
namespace App\Http\Controllers\Api\Public;
use App\Http\Controllers\Controller;
use App\Seo\SitemapGenerator;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
/**
* Sitemap XML 컨트롤러
*
* sitemap.xml 요청을 처리하여 캐시된 또는 실시간 생성된
* Sitemap XML을 반환합니다.
*/
class SitemapController extends Controller
{
/**
* sitemap.xml을 반환합니다.
*
* 캐시가 있으면 캐시된 XML, 없으면 실시간 생성 후 캐시 저장.
*
* @param SitemapGenerator $generator Sitemap 생성기
*/
public function index(SitemapGenerator $generator): Response
{
$enabled = (bool) g7_core_settings('seo.sitemap_enabled', true);
if (! $enabled) {
abort(404);
}
$xml = Cache::get('seo:sitemap');
if (! $xml) {
$xml = $generator->generate();
$ttl = (int) g7_core_settings('seo.sitemap_cache_ttl', 86400);
Cache::put('seo:sitemap', $xml, $ttl);
}
return response($xml, 200, [
'Content-Type' => 'application/xml',
]);
}
}
+12
View File
@@ -0,0 +1,12 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Routing\Controller as BaseController;
abstract class Controller extends BaseController
{
use AuthorizesRequests, ValidatesRequests;
}