v7.0.0-beta.2 release

This commit is contained in:
HeuJung committed 2026-04-20 20:37:49 +09:00
1 parent 6595fd0eb5
commit fa8eba7a0d
1165 files changed
+107118 -762127

No files matched your search

@@ -2,7 +2,6 @@
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;
@@ -42,14 +41,7 @@ class ActivityLogController extends AdminBaseController
$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);
return $this->success('activity_log.fetch_success', $collection->toArray($request));
} catch (Exception $e) {
return $this->error('activity_log.fetch_failed', 500, $e->getMessage());
}
@@ -2,14 +2,12 @@
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
@@ -38,7 +36,7 @@ class AuthController extends AdminBaseController
// 관리자 권한 확인
if (!$user->isAdmin()) {
return ResponseHelper::forbidden('auth.admin_required');
return $this->forbidden('auth.admin_required');
}
// 사용자 정보는 Resource로, 토큰은 그대로
@@ -46,7 +44,7 @@ class AuthController extends AdminBaseController
return $this->success('auth.admin_login_success', $data);
} catch (ValidationException $e) {
return ResponseHelper::unauthorized('auth.login_failed', [], 'core');
return $this->unauthorized('auth.login_failed');
}
}
@@ -99,7 +97,7 @@ class AuthController extends AdminBaseController
}
return $this->success('common.success', $data);
} catch (ValidationException $e) {
return ResponseHelper::unauthorized('auth.unauthenticated', [], 'core');
return $this->unauthorized('auth.unauthenticated');
}
}
}
@@ -0,0 +1,80 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Services\GeoIpDatabaseService;
use Illuminate\Http\JsonResponse;
/**
* GeoIP DB 관리 컨트롤러.
*
* 수동 DB 업데이트 트리거를 제공합니다. 정기 갱신은
* routes/console.php의 스케줄(geoip:update)이 담당합니다.
* 비즈니스 로직은 모두 GeoIpDatabaseService에 위임합니다.
*/
class GeoIpController extends AdminBaseController
{
public function __construct(
private GeoIpDatabaseService $geoIpDatabaseService
) {
parent::__construct();
}
/**
* MaxMind GeoLite2-City DB를 즉시 재다운로드합니다.
*
* 내부적으로 GeoIpDatabaseService::updateDatabase(true)를 호출합니다.
* 다운로드는 동기 실행이므로 PHP-FPM/웹서버 타임아웃(90초 이상) 필요.
*
* @return JsonResponse 업데이트 결과 JSON 응답
*/
public function update(): JsonResponse
{
$result = $this->geoIpDatabaseService->updateDatabase(true);
if ($result['success']) {
return $this->success(
'settings.geoip.update_success',
$result['data'] ?? null
);
}
return $this->error(
$this->mapStatusToMessageKey((string) $result['status']),
$this->mapStatusToHttpCode((string) $result['status']),
$result['message'] ?? null
);
}
/**
* Service 상태 코드를 i18n 메시지 키로 매핑합니다.
*
* @param string $status Service 상태 코드
* @return string i18n 키
*/
private function mapStatusToMessageKey(string $status): string
{
return match ($status) {
'missing_license_key' => 'settings.geoip.license_key_missing',
'unauthorized' => 'settings.geoip.license_key_invalid',
'connection_failed' => 'settings.geoip.connection_failed',
default => 'settings.geoip.update_failed',
};
}
/**
* Service 상태 코드를 HTTP 상태 코드로 매핑합니다.
*
* @param string $status Service 상태 코드
* @return int HTTP 상태 코드
*/
private function mapStatusToHttpCode(string $status): int
{
return match ($status) {
'missing_license_key' => 400,
'unauthorized' => 401,
default => 500,
};
}
}
@@ -1,112 +0,0 @@
<?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);
}
}
}
@@ -1,153 +0,0 @@
<?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);
}
}
}
@@ -2,8 +2,6 @@
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;
@@ -11,6 +9,7 @@ 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\PerformModuleUpdateRequest;
use App\Http\Requests\Module\RefreshModuleLayoutsRequest;
use App\Http\Requests\Module\UninstallModuleRequest;
use App\Http\Requests\Extension\ChangelogRequest;
@@ -62,15 +61,9 @@ class ModuleController extends AdminBaseController
$collection = new ModuleCollection(collect($responseData['data']));
$responseData['data'] = $collection->toArray($request)['data'];
$responseData['meta'] = $collection->with($request)['meta'];
$responseData['abilities'] = $collection->resolveCollectionAbilities($request);
}
// 컬렉션 레벨 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());
@@ -154,8 +147,12 @@ class ModuleController extends AdminBaseController
public function install(InstallModuleRequest $request): JsonResponse
{
try {
$moduleName = $request->validated()['module_name'];
$module = $this->moduleService->installModule($moduleName);
$validated = $request->validated();
$moduleName = $validated['module_name'];
$vendorMode = \App\Extension\Vendor\VendorMode::fromStringOrAuto(
$validated['vendor_mode'] ?? null
);
$module = $this->moduleService->installModule($moduleName, $vendorMode);
if ($module) {
return $this->successWithResource(
@@ -172,7 +169,7 @@ class ModuleController extends AdminBaseController
$firstError = collect($e->errors())->flatten()->first()
?? __('module.install_failed');
return ResponseHelper::error($firstError, 422, $e->errors());
return $this->validationError($e->errors(), $firstError);
} catch (\Exception $e) {
return $this->error('modules.installation_failed', 500, $e->getMessage(), [
'error' => $e->getMessage(),
@@ -411,16 +408,48 @@ class ModuleController extends AdminBaseController
}
}
/**
* 특정 모듈의 수정된 레이아웃을 확인합니다.
*
* 업데이트 전 사용자가 수정한 레이아웃이 있는지 확인하여
* 레이아웃 전략(overwrite/keep) 선택에 참고할 수 있도록 합니다.
*
* @param string $moduleName 모듈 식별자
* @return JsonResponse 수정된 레이아웃 정보를 포함한 JSON 응답
*/
public function checkModifiedLayouts(string $moduleName): JsonResponse
{
try {
$result = $this->moduleService->checkModifiedLayouts($moduleName);
return $this->success('modules.check_modified_layouts_success', $result);
} catch (ValidationException $e) {
return $this->error('modules.check_modified_layouts_failed', 422, $e->errors());
} catch (\Exception $e) {
return $this->error('modules.check_modified_layouts_failed', 500, $e->getMessage());
}
}
/**
* 특정 모듈을 업데이트합니다.
*
* layout_strategy 파라미터로 레이아웃 처리 방식을 결정합니다:
* - overwrite: 모든 레이아웃을 새 버전으로 교체
* - keep: 사용자가 수정한 레이아웃을 유지
*
* @param PerformModuleUpdateRequest $request 업데이트 요청 데이터
* @param string $moduleName 업데이트할 모듈 identifier
* @return JsonResponse 업데이트 결과 JSON 응답
*/
public function performUpdate(string $moduleName): JsonResponse
public function performUpdate(PerformModuleUpdateRequest $request, string $moduleName): JsonResponse
{
try {
$result = $this->moduleService->updateModule($moduleName);
$validated = $request->validated();
$vendorMode = \App\Extension\Vendor\VendorMode::fromStringOrAuto(
$validated['vendor_mode'] ?? null
);
$layoutStrategy = $validated['layout_strategy'] ?? 'overwrite';
$result = $this->moduleService->updateModule($moduleName, $vendorMode, $layoutStrategy);
$moduleInfo = $result['module_info'] ?? null;
@@ -438,7 +467,7 @@ class ModuleController extends AdminBaseController
$firstError = collect($e->errors())->flatten()->first()
?? __('modules.errors.update_failed', ['module' => $moduleName, 'error' => '']);
return ResponseHelper::error($firstError, 422, $e->errors());
return $this->validationError($e->errors(), $firstError);
} catch (\Exception $e) {
return $this->error('modules.errors.update_failed', 500, $e->getMessage(), [
'module' => $moduleName,
@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Contracts\Notifications\ChannelReadinessCheckerInterface;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Services\NotificationChannelService;
use Illuminate\Support\Facades\Log;
/**
* 알림 채널 관리 컨트롤러
*
* 시스템에서 사용 가능한 알림 채널 목록을 제공합니다.
* 플러그인이 Filter 훅으로 채널을 확장할 수 있습니다.
*/
class NotificationChannelController extends AdminBaseController
{
/**
* @param NotificationChannelService $channelService
*/
public function __construct(
private readonly NotificationChannelService $channelService,
private readonly ChannelReadinessCheckerInterface $readinessChecker,
) {
parent::__construct();
}
/**
* 사용 가능한 알림 채널 목록을 반환합니다.
*
* 각 채널에 readiness (설정 완료 여부) 정보를 포함합니다.
*
* @return \Illuminate\Http\JsonResponse
*/
public function index()
{
try {
$channels = $this->channelService->getAvailableChannels();
// 각 채널에 readiness 정보 추가
$channels = array_map(function (array $channel) {
$channelId = $channel['id'] ?? '';
$channel['readiness'] = $this->readinessChecker->check($channelId);
return $channel;
}, $channels);
return $this->success(
__('notification.channels_success'),
['channels' => $channels]
);
} catch (\Exception $e) {
Log::error('알림 채널 목록 조회 실패', ['error' => $e->getMessage()]);
return $this->error(__('notification.channels_failed'), 500);
}
}
}
@@ -3,29 +3,170 @@
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Notification\NotificationBatchReadRequest;
use App\Http\Requests\Notification\NotificationIndexRequest;
use App\Http\Resources\UserNotificationCollection;
use App\Http\Resources\UserNotificationResource;
use App\Services\NotificationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
/**
* 관리자 알림 컨트롤러 (더미)
* 관리자 알림 컨트롤러
*
* 추후 알림 기능 구현 예정. 현재는 빈 목록을 반환합니다.
* 관리자의 사이트내 알림을 관리합니다.
*/
class NotificationController extends AdminBaseController
{
public function __construct(
private readonly NotificationService $notificationService,
) {
parent::__construct();
}
/**
* 알림 목록을 조회합니다.
*
* @return JsonResponse 알림 목록 (현재 빈 배열)
*/
public function index(): JsonResponse
public function index(NotificationIndexRequest $request): JsonResponse
{
// TODO: 실제 알림 기능 구현 시 교체 예정
return $this->success('messages.success', [
'data' => [],
'meta' => [
'total' => 0,
'unread_count' => 0,
],
]);
try {
$validated = $request->validated();
$user = $request->user();
$perPage = (int) ($validated['per_page'] ?? 15);
$filters = collect($validated)->only(['read'])->filter(fn ($v) => $v !== null)->toArray();
$notifications = $this->notificationService->getNotifications($user, $filters, $perPage);
return $this->success(
__('notification.user.list_success'),
new UserNotificationCollection($notifications)
);
} catch (\Exception $e) {
Log::error('관리자 알림 목록 조회 실패', ['error' => $e->getMessage()]);
return $this->error(__('notification.user.list_failed'), 500);
}
}
/**
* 미읽음 알림 수를 반환합니다.
*/
public function unreadCount(Request $request): JsonResponse
{
try {
$count = $this->notificationService->getUnreadCount($request->user());
return $this->success(
__('notification.user.unread_count_success'),
['unread_count' => $count]
);
} catch (\Exception $e) {
Log::error('관리자 미읽음 알림 수 조회 실패', ['error' => $e->getMessage()]);
return $this->error(__('notification.user.unread_count_failed'), 500);
}
}
/**
* 알림을 읽음 처리합니다.
*/
public function markAsRead(Request $request, string $id): JsonResponse
{
try {
$notification = $this->notificationService->markAsRead($request->user(), $id);
if (! $notification) {
return $this->error(__('notification.user.not_found'), 404);
}
return $this->success(
__('notification.user.read_success'),
new UserNotificationResource($notification)
);
} catch (\Exception $e) {
Log::error('관리자 알림 읽음 처리 실패', ['id' => $id, 'error' => $e->getMessage()]);
return $this->error(__('notification.user.read_failed'), 500);
}
}
/**
* 지정된 알림들을 일괄 읽음 처리합니다.
*/
public function markBatchAsRead(NotificationBatchReadRequest $request): JsonResponse
{
try {
$ids = $request->validated()['ids'];
$count = $this->notificationService->markBatchAsRead($request->user(), $ids);
return $this->success(
__('notification.user.read_success'),
['marked_count' => $count]
);
} catch (\Exception $e) {
Log::error('관리자 알림 일괄 읽음 처리 실패', ['error' => $e->getMessage()]);
return $this->error(__('notification.user.read_failed'), 500);
}
}
/**
* 모든 알림을 읽음 처리합니다.
*/
public function markAllAsRead(Request $request): JsonResponse
{
try {
$count = $this->notificationService->markAllAsRead($request->user());
return $this->success(
__('notification.user.read_all_success'),
['marked_count' => $count]
);
} catch (\Exception $e) {
Log::error('관리자 전체 읽음 처리 실패', ['error' => $e->getMessage()]);
return $this->error(__('notification.user.read_all_failed'), 500);
}
}
/**
* 사용자의 모든 알림을 삭제합니다.
*/
public function destroyAll(Request $request): JsonResponse
{
try {
$count = $this->notificationService->deleteAllNotifications($request->user());
return $this->success(
__('notification.user.delete_all_success'),
['deleted_count' => $count]
);
} catch (\Exception $e) {
Log::error('관리자 전체 알림 삭제 실패', ['error' => $e->getMessage()]);
return $this->error(__('notification.user.delete_all_failed'), 500);
}
}
/**
* 알림을 삭제합니다.
*/
public function destroy(Request $request, string $id): JsonResponse
{
try {
$deleted = $this->notificationService->deleteNotification($request->user(), $id);
if (! $deleted) {
return $this->error(__('notification.user.not_found'), 404);
}
return $this->success(__('notification.user.delete_success'));
} catch (\Exception $e) {
Log::error('관리자 알림 삭제 실패', ['id' => $id, 'error' => $e->getMessage()]);
return $this->error(__('notification.user.delete_failed'), 500);
}
}
}
@@ -0,0 +1,176 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\NotificationDefinition\NotificationDefinitionIndexRequest;
use App\Http\Requests\NotificationDefinition\UpdateNotificationDefinitionRequest;
use App\Http\Resources\NotificationDefinitionCollection;
use App\Http\Resources\NotificationDefinitionResource;
use App\Models\NotificationDefinition;
use App\Services\NotificationDefinitionService;
use App\Services\NotificationTemplateService;
use Illuminate\Support\Facades\Log;
/**
* 알림 정의 관리 컨트롤러
*/
class NotificationDefinitionController extends AdminBaseController
{
/**
* @param NotificationDefinitionService $definitionService
*/
public function __construct(
private readonly NotificationDefinitionService $definitionService,
private readonly NotificationTemplateService $templateService,
) {
parent::__construct();
}
/**
* 알림 정의 목록을 조회합니다.
*
* @param NotificationDefinitionIndexRequest $request
* @return \Illuminate\Http\JsonResponse
*/
public function index(NotificationDefinitionIndexRequest $request)
{
try {
$filters = $request->validated();
$perPage = $filters['per_page'] ?? 20;
$definitions = $this->definitionService->getDefinitions($filters, $perPage);
$collection = new NotificationDefinitionCollection($definitions);
return $this->success(
__('notification.definition_list_success'),
$collection->toArray($request)
);
} catch (\Exception $e) {
Log::error('알림 정의 목록 조회 실패', ['error' => $e->getMessage()]);
return $this->error(__('notification.definition_list_failed'), 500);
}
}
/**
* 알림 정의 상세를 조회합니다.
*
* @param NotificationDefinition $definition
* @return \Illuminate\Http\JsonResponse
*/
public function show(NotificationDefinition $definition)
{
try {
$definition->load('templates');
return $this->success(
__('notification.definition_show_success'),
new NotificationDefinitionResource($definition)
);
} catch (\Exception $e) {
Log::error('알림 정의 상세 조회 실패', [
'definition_id' => $definition->id,
'error' => $e->getMessage(),
]);
return $this->error(__('notification.definition_show_failed'), 500);
}
}
/**
* 알림 정의를 수정합니다.
*
* @param UpdateNotificationDefinitionRequest $request
* @param NotificationDefinition $definition
* @return \Illuminate\Http\JsonResponse
*/
public function update(UpdateNotificationDefinitionRequest $request, NotificationDefinition $definition)
{
try {
$updated = $this->definitionService->updateDefinition(
$definition,
$request->validated(),
$this->getCurrentAdmin()?->id
);
return $this->success(
__('notification.definition_updated'),
new NotificationDefinitionResource($updated->load('templates'))
);
} catch (\Exception $e) {
Log::error('알림 정의 수정 실패', [
'definition_id' => $definition->id,
'error' => $e->getMessage(),
]);
return $this->error(__('notification.definition_update_failed'), 500);
}
}
/**
* 알림 정의 활성/비활성을 토글합니다.
*
* @param NotificationDefinition $definition
* @return \Illuminate\Http\JsonResponse
*/
public function toggleActive(NotificationDefinition $definition)
{
try {
$updated = $this->definitionService->toggleActive($definition);
return $this->success(
__('notification.definition_toggled'),
new NotificationDefinitionResource($updated)
);
} catch (\Exception $e) {
Log::error('알림 정의 활성 토글 실패', [
'definition_id' => $definition->id,
'error' => $e->getMessage(),
]);
return $this->error(__('notification.definition_toggle_failed'), 500);
}
}
/**
* 알림 정의의 모든 템플릿을 기본값으로 일괄 복원합니다.
*
* @param NotificationDefinition $definition
* @return \Illuminate\Http\JsonResponse
*/
public function reset(NotificationDefinition $definition)
{
try {
$definition->load('templates');
$resetCount = 0;
foreach ($definition->templates as $template) {
$defaultData = $this->templateService->getDefaultTemplateData(
$definition->type,
$template->channel
);
if (! empty($defaultData)) {
$this->templateService->resetToDefault($template, $defaultData);
$resetCount++;
}
}
$this->definitionService->markAsDefault($definition);
$definition->load('templates');
return $this->success(
__('notification.definition_reset'),
new NotificationDefinitionResource($definition)
);
} catch (\Exception $e) {
Log::error('알림 정의 초기화 실패', [
'definition_id' => $definition->id,
'error' => $e->getMessage(),
]);
return $this->error(__('notification.definition_reset_failed'), 500);
}
}
}
@@ -0,0 +1,87 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\NotificationLog\NotificationLogBulkDeleteRequest;
use App\Http\Requests\NotificationLog\NotificationLogDeleteRequest;
use App\Http\Requests\NotificationLog\NotificationLogIndexRequest;
use App\Http\Resources\NotificationLogCollection;
use App\Models\NotificationLog;
use App\Services\NotificationLogService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
/**
* 알림 발송 이력 관리 컨트롤러
*/
class NotificationLogController extends AdminBaseController
{
public function __construct(
private readonly NotificationLogService $logService,
) {
parent::__construct();
}
/**
* 알림 발송 이력 목록을 조회합니다.
*/
public function index(NotificationLogIndexRequest $request): JsonResponse
{
try {
$filters = $request->validated();
$perPage = $filters['per_page'] ?? 20;
$logs = $this->logService->getLogs($filters, $perPage, $request->user());
$collection = new NotificationLogCollection($logs);
return $this->success(
__('notification_log.list_success'),
$collection->toArray($request)
);
} catch (\Exception $e) {
Log::error('알림 발송 이력 목록 조회 실패', ['error' => $e->getMessage()]);
return $this->error(__('notification_log.list_failed'), 500);
}
}
/**
* 알림 발송 이력을 삭제합니다.
*/
public function destroy(NotificationLogDeleteRequest $request, NotificationLog $notificationLog): JsonResponse
{
try {
$this->logService->deleteLog($notificationLog);
return $this->success(__('notification_log.delete_success'));
} catch (\Exception $e) {
Log::error('알림 발송 이력 삭제 실패', [
'id' => $notificationLog->id,
'error' => $e->getMessage(),
]);
return $this->error(__('notification_log.delete_failed'), 500);
}
}
/**
* 알림 발송 이력을 다건 삭제합니다.
*/
public function bulkDestroy(NotificationLogBulkDeleteRequest $request): JsonResponse
{
try {
$ids = $request->validated()['ids'];
$count = $this->logService->bulkDelete($ids);
return $this->success(
__('notification_log.bulk_delete_success'),
['deleted_count' => $count]
);
} catch (\Exception $e) {
Log::error('알림 발송 이력 다건 삭제 실패', ['error' => $e->getMessage()]);
return $this->error(__('notification_log.bulk_delete_failed'), 500);
}
}
}
@@ -0,0 +1,136 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\NotificationTemplate\PreviewNotificationTemplateRequest;
use App\Http\Requests\NotificationTemplate\UpdateNotificationTemplateRequest;
use App\Http\Resources\NotificationTemplateResource;
use App\Models\NotificationTemplate;
use App\Services\NotificationTemplateService;
use Illuminate\Support\Facades\Log;
/**
* 알림 템플릿 관리 컨트롤러
*/
class NotificationTemplateController extends AdminBaseController
{
/**
* @param NotificationTemplateService $templateService
*/
public function __construct(
private readonly NotificationTemplateService $templateService,
) {
parent::__construct();
}
/**
* 알림 템플릿을 수정합니다.
*
* @param UpdateNotificationTemplateRequest $request
* @param NotificationTemplate $template
* @return \Illuminate\Http\JsonResponse
*/
public function update(UpdateNotificationTemplateRequest $request, NotificationTemplate $template)
{
try {
$updated = $this->templateService->updateTemplate(
$template,
$request->validated(),
$this->getCurrentAdmin()?->id
);
return $this->success(
__('notification.template_updated'),
new NotificationTemplateResource($updated)
);
} catch (\Exception $e) {
Log::error('알림 템플릿 수정 실패', [
'template_id' => $template->id,
'error' => $e->getMessage(),
]);
return $this->error(__('notification.template_update_failed'), 500);
}
}
/**
* 알림 템플릿 활성/비활성을 토글합니다.
*
* @param NotificationTemplate $template
* @return \Illuminate\Http\JsonResponse
*/
public function toggleActive(NotificationTemplate $template)
{
try {
$updated = $this->templateService->toggleActive($template);
return $this->success(
__('notification.template_toggled'),
new NotificationTemplateResource($updated)
);
} catch (\Exception $e) {
Log::error('알림 템플릿 활성 토글 실패', [
'template_id' => $template->id,
'error' => $e->getMessage(),
]);
return $this->error(__('notification.template_toggle_failed'), 500);
}
}
/**
* 알림 템플릿 미리보기를 반환합니다.
*
* @param PreviewNotificationTemplateRequest $request
* @return \Illuminate\Http\JsonResponse
*/
public function preview(PreviewNotificationTemplateRequest $request)
{
try {
$result = $this->templateService->getPreview($request->validated());
return $this->success(__('notification.preview_success'), $result);
} catch (\Exception $e) {
Log::error('알림 템플릿 미리보기 실패', ['error' => $e->getMessage()]);
return $this->error(__('notification.preview_failed'), 500);
}
}
/**
* 알림 템플릿을 기본값으로 복원합니다.
*
* @param NotificationTemplate $template
* @return \Illuminate\Http\JsonResponse
*/
public function reset(NotificationTemplate $template)
{
try {
$definition = $template->definition;
if (! $definition) {
return $this->error(__('notification.definition_not_found'), 404);
}
$defaultData = $this->templateService->getDefaultTemplateData($definition->type, $template->channel);
if (empty($defaultData)) {
return $this->error(__('notification.default_data_not_found'), 404);
}
$updated = $this->templateService->resetToDefault($template, $defaultData);
return $this->success(
__('notification.template_reset'),
new NotificationTemplateResource($updated)
);
} catch (\Exception $e) {
Log::error('알림 템플릿 초기화 실패', [
'template_id' => $template->id,
'error' => $e->getMessage(),
]);
return $this->error(__('notification.template_reset_failed'), 500);
}
}
}
@@ -3,7 +3,6 @@
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;
@@ -11,6 +10,7 @@ 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\PerformPluginUpdateRequest;
use App\Http\Requests\Plugin\RefreshPluginLayoutsRequest;
use App\Http\Requests\Plugin\UninstallPluginRequest;
use App\Http\Requests\Extension\ChangelogRequest;
@@ -139,8 +139,12 @@ class PluginController extends AdminBaseController
public function install(InstallPluginRequest $request): JsonResponse
{
try {
$pluginName = $request->validated()['plugin_name'];
$pluginInfo = $this->pluginService->installPlugin($pluginName);
$validated = $request->validated();
$pluginName = $validated['plugin_name'];
$vendorMode = \App\Extension\Vendor\VendorMode::fromStringOrAuto(
$validated['vendor_mode'] ?? null
);
$pluginInfo = $this->pluginService->installPlugin($pluginName, $vendorMode);
if ($pluginInfo) {
return $this->successWithResource(
@@ -156,7 +160,7 @@ class PluginController extends AdminBaseController
$firstError = collect($e->errors())->flatten()->first()
?? __('plugins.install_failed');
return ResponseHelper::error($firstError, 422, $e->errors());
return $this->validationError($e->errors(), $firstError);
} catch (\Exception $e) {
return $this->error('plugins.installation_failed', 500, $e->getMessage(), [
'error' => $e->getMessage(),
@@ -419,16 +423,45 @@ class PluginController extends AdminBaseController
}
}
/**
* 특정 플러그인의 수정된 레이아웃을 확인합니다.
*
* @param string $pluginName 플러그인 식별자
* @return JsonResponse 수정된 레이아웃 정보를 포함한 JSON 응답
*/
public function checkModifiedLayouts(string $pluginName): JsonResponse
{
try {
$result = $this->pluginService->checkModifiedLayouts($pluginName);
return $this->success('plugins.check_modified_layouts_success', $result);
} catch (ValidationException $e) {
return $this->error('plugins.check_modified_layouts_failed', 422, $e->errors());
} catch (\Exception $e) {
return $this->error('plugins.check_modified_layouts_failed', 500, $e->getMessage());
}
}
/**
* 특정 플러그인을 업데이트합니다.
*
* layout_strategy 파라미터로 레이아웃 처리 방식을 결정합니다:
* - overwrite: 모든 레이아웃을 새 버전으로 교체
* - keep: 사용자가 수정한 레이아웃을 유지
*
* @param PerformPluginUpdateRequest $request 업데이트 요청 데이터
* @param string $pluginName 업데이트할 플러그인 identifier
* @return JsonResponse 업데이트 결과 JSON 응답
*/
public function performUpdate(string $pluginName): JsonResponse
public function performUpdate(PerformPluginUpdateRequest $request, string $pluginName): JsonResponse
{
try {
$result = $this->pluginService->updatePlugin($pluginName);
$validated = $request->validated();
$vendorMode = \App\Extension\Vendor\VendorMode::fromStringOrAuto(
$validated['vendor_mode'] ?? null
);
$layoutStrategy = $validated['layout_strategy'] ?? 'overwrite';
$result = $this->pluginService->updatePlugin($pluginName, $vendorMode, $layoutStrategy);
$pluginInfo = $result['plugin_info'] ?? null;
@@ -446,7 +479,7 @@ class PluginController extends AdminBaseController
$firstError = collect($e->errors())->flatten()->first()
?? __('plugins.errors.update_failed', ['plugin' => $pluginName, 'error' => '']);
return ResponseHelper::error($firstError, 422, $e->errors());
return $this->validationError($e->errors(), $firstError);
} catch (\Exception $e) {
return $this->error('plugins.errors.update_failed', 500, $e->getMessage(), [
'plugin' => $pluginName,
@@ -10,6 +10,7 @@ use App\Http\Requests\Settings\TestMailRequest;
use App\Http\Requests\Settings\UpdateSettingRequest;
use App\Http\Resources\SettingsResource;
use App\Services\DriverConnectionTester;
use App\Services\DriverRegistryService;
use App\Services\SettingsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -24,7 +25,8 @@ class SettingsController extends AdminBaseController
{
public function __construct(
private SettingsService $settingsService,
private DriverConnectionTester $driverConnectionTester
private DriverConnectionTester $driverConnectionTester,
private DriverRegistryService $driverRegistryService
) {
parent::__construct();
}
@@ -38,6 +40,7 @@ class SettingsController extends AdminBaseController
{
try {
$settings = $this->settingsService->getAllSettings();
$settings['available_drivers'] = $this->driverRegistryService->getAllAvailableDrivers();
return $this->success('settings.fetch_success',
(new SettingsResource($settings))->toArray(request())
@@ -64,6 +67,7 @@ class SettingsController extends AdminBaseController
if ($result) {
// 저장 후 전체 설정 반환 (관리자 UI 상태 업데이트용)
$allSettings = $this->settingsService->getAllSettings();
$allSettings['available_drivers'] = $this->driverRegistryService->getAllAvailableDrivers();
return $this->success('settings.save_success', [
'settings' => $allSettings,
@@ -3,7 +3,6 @@
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;
@@ -135,7 +134,7 @@ class TemplateController extends AdminBaseController
$firstError = collect($e->errors())->flatten()->first()
?? __('templates.install_failed');
return ResponseHelper::error($firstError, 422, $e->errors());
return $this->validationError($e->errors(), $firstError);
} catch (\Exception $e) {
return $this->error('templates.errors.installation_failed', 500, $e->getMessage(), [
'error' => $e->getMessage(),
@@ -414,7 +413,7 @@ class TemplateController extends AdminBaseController
$firstError = collect($e->errors())->flatten()->first()
?? __('templates.errors.update_failed', ['template' => $templateName, 'error' => '']);
return ResponseHelper::error($firstError, 422, $e->errors());
return $this->validationError($e->errors(), $firstError);
} catch (\Exception $e) {
return $this->error('templates.errors.update_failed', 500, $e->getMessage(), [
'template' => $templateName,
@@ -23,7 +23,7 @@ use Illuminate\Validation\ValidationException;
/**
* 관리자용 사용자 관리 컨트롤러
*
*
* 관리자가 시스템 사용자들을 관리할 수 있는 기능을 제공합니다.
*/
class UserController extends AdminBaseController
@@ -78,7 +78,7 @@ class UserController extends AdminBaseController
} catch (ValidationException $e) {
return $this->error('user.create_failed', 422, $e->errors());
} catch (Exception $e) {
return $this->error('user.create_failed', 500, $e->getMessage());
return $this->error('user.create_failed', 500, $e, ['error' => $e->getMessage()]);
}
}
@@ -126,7 +126,7 @@ class UserController extends AdminBaseController
} catch (ValidationException $e) {
return $this->error('user.update_failed', 422, $e->errors());
} catch (Exception $e) {
return $this->error('user.update_failed', 500, $e->getMessage());
return $this->error('user.update_failed', 500, $e, ['error' => $e->getMessage()]);
}
}
@@ -154,7 +154,7 @@ class UserController extends AdminBaseController
} catch (ValidationException $e) {
return $this->error('user.delete_failed', 422, $e->errors());
} catch (Exception $e) {
return $this->error('user.delete_failed', 500);
return $this->error('user.delete_failed', 500, $e, ['error' => $e->getMessage()]);
}
}
@@ -0,0 +1,172 @@
<?php
namespace App\Http\Controllers\Api\Auth;
use App\Http\Controllers\Api\Base\AuthBaseController;
use App\Http\Requests\Notification\NotificationBatchReadRequest;
use App\Http\Requests\Notification\NotificationIndexRequest;
use App\Http\Resources\UserNotificationCollection;
use App\Http\Resources\UserNotificationResource;
use App\Services\NotificationService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
/**
* 사용자 알림 컨트롤러
*
* 인증된 사용자의 사이트내 알림을 관리합니다.
*/
class NotificationController extends AuthBaseController
{
public function __construct(
private readonly NotificationService $notificationService,
) {
parent::__construct();
}
/**
* 알림 목록을 조회합니다.
*/
public function index(NotificationIndexRequest $request): JsonResponse
{
try {
$validated = $request->validated();
$user = $request->user();
$perPage = (int) ($validated['per_page'] ?? 20);
$filters = collect($validated)->only(['read'])->filter(fn ($v) => $v !== null)->toArray();
$notifications = $this->notificationService->getNotifications($user, $filters, $perPage);
return $this->success(
__('notification.user.list_success'),
new UserNotificationCollection($notifications)
);
} catch (\Exception $e) {
Log::error('알림 목록 조회 실패', ['error' => $e->getMessage()]);
return $this->error(__('notification.user.list_failed'), 500);
}
}
/**
* 미읽음 알림 수를 반환합니다.
*/
public function unreadCount(Request $request): JsonResponse
{
try {
$count = $this->notificationService->getUnreadCount($request->user());
return $this->success(
__('notification.user.unread_count_success'),
['unread_count' => $count]
);
} catch (\Exception $e) {
Log::error('미읽음 알림 수 조회 실패', ['error' => $e->getMessage()]);
return $this->error(__('notification.user.unread_count_failed'), 500);
}
}
/**
* 알림을 읽음 처리합니다.
*/
public function markAsRead(Request $request, string $id): JsonResponse
{
try {
$notification = $this->notificationService->markAsRead($request->user(), $id);
if (! $notification) {
return $this->error(__('notification.user.not_found'), 404);
}
return $this->success(
__('notification.user.read_success'),
new UserNotificationResource($notification)
);
} catch (\Exception $e) {
Log::error('알림 읽음 처리 실패', ['id' => $id, 'error' => $e->getMessage()]);
return $this->error(__('notification.user.read_failed'), 500);
}
}
/**
* 지정된 알림들을 일괄 읽음 처리합니다.
*/
public function markBatchAsRead(NotificationBatchReadRequest $request): JsonResponse
{
try {
$ids = $request->validated()['ids'];
$count = $this->notificationService->markBatchAsRead($request->user(), $ids);
return $this->success(
__('notification.user.read_success'),
['marked_count' => $count]
);
} catch (\Exception $e) {
Log::error('알림 일괄 읽음 처리 실패', ['error' => $e->getMessage()]);
return $this->error(__('notification.user.read_failed'), 500);
}
}
/**
* 모든 알림을 읽음 처리합니다.
*/
public function markAllAsRead(Request $request): JsonResponse
{
try {
$count = $this->notificationService->markAllAsRead($request->user());
return $this->success(
__('notification.user.read_all_success'),
['marked_count' => $count]
);
} catch (\Exception $e) {
Log::error('전체 읽음 처리 실패', ['error' => $e->getMessage()]);
return $this->error(__('notification.user.read_all_failed'), 500);
}
}
/**
* 사용자의 모든 알림을 삭제합니다.
*/
public function destroyAll(Request $request): JsonResponse
{
try {
$count = $this->notificationService->deleteAllNotifications($request->user());
return $this->success(
__('notification.user.delete_all_success'),
['deleted_count' => $count]
);
} catch (\Exception $e) {
Log::error('전체 알림 삭제 실패', ['error' => $e->getMessage()]);
return $this->error(__('notification.user.delete_all_failed'), 500);
}
}
/**
* 알림을 삭제합니다.
*/
public function destroy(Request $request, string $id): JsonResponse
{
try {
$deleted = $this->notificationService->deleteNotification($request->user(), $id);
if (! $deleted) {
return $this->error(__('notification.user.not_found'), 404);
}
return $this->success(__('notification.user.delete_success'));
} catch (\Exception $e) {
Log::error('알림 삭제 실패', ['id' => $id, 'error' => $e->getMessage()]);
return $this->error(__('notification.user.delete_failed'), 500);
}
}
}
@@ -130,8 +130,7 @@ class ProfileController extends AuthBaseController
return $this->unauthorized('auth.unauthenticated');
}
// TODO: 사용자 활동 로그 서비스 구현
$activities = []; // $this->userService->getUserActivities($user->id);
$activities = $this->userService->getUserActivityLogs($user->id);
$this->logUserActivity('profile.activity_log');
@@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api\Base;
use App\Contracts\Extension\CacheInterface;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
@@ -22,6 +23,10 @@ abstract class PublicBaseController extends BaseApiController
/**
* 캐시된 응답을 반환하거나 새로 생성합니다.
*
* 주의: 콜백 반환값이 배열이고 `error` 키가 있으면 캐시하지 않는다.
* 이는 확장 설치 직후·활성화 전 등의 일시적 상태에서 얻은 "not found" 같은
* 에러 응답이 영구 캐시되어 복구 후에도 잘못된 응답을 반환하는 문제를 방지한다.
*
* @param string $key 캐시 키
* @param callable $callback 데이터 생성 콜백
* @param int $ttl 캐시 유지 시간 (초)
@@ -29,7 +34,21 @@ abstract class PublicBaseController extends BaseApiController
*/
protected function cached(string $key, callable $callback, int $ttl = 3600)
{
return cache()->remember($key, $ttl, $callback);
$cache = app(CacheInterface::class);
$cached = $cache->get($key);
if ($cached !== null) {
return $cached;
}
$result = $callback();
// 에러 응답은 캐시하지 않음 (일시적 상태 회복 가능성 보장)
if (! (is_array($result) && isset($result['error']))) {
$cache->put($key, $result, $ttl);
}
return $result;
}
/**
@@ -57,7 +57,7 @@ class PublicSearchController extends PublicBaseController
$response = $this->buildResponse($q, $results, $context);
return $this->success(
__('search.results_found', ['count' => $response['total']]),
__('search.results_found', ['count' => number_format($response['total'])]),
$response
);
}
@@ -2,10 +2,10 @@
namespace App\Http\Controllers\Api\Public;
use App\Contracts\Extension\CacheInterface;
use App\Http\Controllers\Controller;
use App\Seo\SitemapGenerator;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Cache;
/**
* Sitemap XML 컨트롤러
@@ -21,20 +21,21 @@ class SitemapController extends Controller
* 캐시가 있으면 캐시된 XML, 없으면 실시간 생성 후 캐시 저장.
*
* @param SitemapGenerator $generator Sitemap 생성기
* @param CacheInterface $cache 코어 캐시 드라이버
*/
public function index(SitemapGenerator $generator): Response
public function index(SitemapGenerator $generator, CacheInterface $cache): Response
{
$enabled = (bool) g7_core_settings('seo.sitemap_enabled', true);
if (! $enabled) {
abort(404);
}
$xml = Cache::get('seo:sitemap');
$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);
$ttl = (int) g7_core_settings('cache.seo_sitemap_ttl', g7_core_settings('seo.sitemap_cache_ttl', 86400));
$cache->put('seo.sitemap', $xml, $ttl);
}
return response($xml, 200, [
+35 -10
View File
@@ -52,35 +52,60 @@ class SetTimezone
*/
private function determineTimezone(Request $request): string
{
$supportedTimezones = config('app.supported_timezones', ['Asia/Seoul', 'UTC']);
// 1. 인증된 사용자의 타임존 설정 확인 (최우선)
if (Auth::check() && Auth::user()->timezone) {
$userTimezone = Auth::user()->timezone;
if (in_array($userTimezone, $supportedTimezones)) {
if ($this->isValidTimezone($userTimezone)) {
return $userTimezone;
}
}
// 2. X-Timezone 헤더 확인 (브라우저 타임존)
$browserTimezone = $request->header('X-Timezone');
if ($browserTimezone && in_array($browserTimezone, $supportedTimezones)) {
if ($browserTimezone && $this->isValidTimezone($browserTimezone)) {
return $browserTimezone;
}
// 3. IP 기반 GeoIP 타임존 감지
$geoTimezone = $this->geoIpService->getTimezoneByIp(
$request->ip(),
$supportedTimezones
);
if ($geoTimezone) {
return $geoTimezone;
// 마스터 스위치(관리자 > 환경설정 > 고급)가 OFF면 이 단계 전체 스킵.
// g7_core_settings()는 코어 설정의 정식 읽기 경로로, config('geoip.enabled')
// 파이프라인(SettingsServiceProvider::applyGeoIpConfig)이 실패하더라도
// 직접 소스 오브 트루스를 참조하여 안전하게 동작한다.
// 전 지역 지원 정책(GeoIpService 두 번째 인자)에 따라 IANA 전체 목록을 전달한다.
if ((bool) g7_core_settings('geoip.feature_enabled', false)) {
$geoTimezone = $this->geoIpService->getTimezoneByIp(
$request->ip(),
\DateTimeZone::listIdentifiers()
);
if ($geoTimezone) {
return $geoTimezone;
}
}
// 4. 기본값 반환
return config('app.default_user_timezone', 'Asia/Seoul');
}
/**
* 주어진 문자열이 유효한 IANA 타임존 식별자인지 확인합니다.
*
* PHP 내장 DateTimeZone 생성자를 사용하여 검증합니다.
* 전 지역 IANA 타임존을 허용하므로 별도 화이트리스트를 사용하지 않습니다.
*
* @param string $timezone 검증할 타임존 문자열
* @return bool 유효 여부
*/
private function isValidTimezone(string $timezone): bool
{
try {
new \DateTimeZone($timezone);
return true;
} catch (\Exception) {
return false;
}
}
/**
* 현재 요청에 설정된 타임존을 가져옵니다.
*
@@ -195,6 +195,10 @@ class UpdateLayoutContentRequest extends FormRequest
'content.transition_overlay.spinner' => ['nullable', 'array'],
'content.transition_overlay.spinner.component' => ['nullable', 'string', 'max:100'],
'content.transition_overlay.spinner.text' => ['nullable', 'string', 'max:200'],
// wait_for: spinner 가 명시된 progressive/blocking 데이터소스 fetch 완료까지 표시되도록 가드
// background/websocket 데이터소스는 의도상 사용자 차단 불가 → withValidator 에서 cross-field 검증
'content.transition_overlay.wait_for' => ['nullable', 'array'],
'content.transition_overlay.wait_for.*' => ['string', 'max:100'],
];
// extends 레이아웃 또는 Base 레이아웃이 아닌 경우에만 endpoint, components 필수
@@ -225,6 +229,54 @@ class UpdateLayoutContentRequest extends FormRequest
return HookManager::applyFilters('core.layout.update_content_validation_rules', $rules, $this);
}
/**
* Cross-field 검증 — transition_overlay.wait_for 가 가리키는 데이터소스의 type/loading_strategy 검증
*
* wait_for 는 spinner 가 fetch 완료까지 표시되어야 할 데이터소스 ID 목록이지만,
* 의미상 사용자를 차단할 수 없는 background/websocket 데이터소스는 사전에 차단한다.
*/
public function withValidator(\Illuminate\Contracts\Validation\Validator $validator): void
{
$validator->after(function (\Illuminate\Contracts\Validation\Validator $v): void {
$waitFor = $this->input('content.transition_overlay.wait_for');
if (! is_array($waitFor) || empty($waitFor)) {
return;
}
$dataSources = $this->input('content.data_sources');
if (! is_array($dataSources)) {
return;
}
$byId = [];
foreach ($dataSources as $source) {
if (is_array($source) && isset($source['id'])) {
$byId[$source['id']] = $source;
}
}
foreach ($waitFor as $index => $id) {
if (! is_string($id) || ! isset($byId[$id])) {
continue; // 미존재 ID 는 엔진에서 자동 무시됨 (가드 무시)
}
$source = $byId[$id];
$type = $source['type'] ?? 'api';
$strategy = $source['loading_strategy'] ?? 'progressive';
if ($type === 'websocket') {
$v->errors()->add(
"content.transition_overlay.wait_for.$index",
__('validation.layout.transition_overlay.wait_for.websocket', ['id' => $id])
);
} elseif ($strategy === 'background') {
$v->errors()->add(
"content.transition_overlay.wait_for.$index",
__('validation.layout.transition_overlay.wait_for.background', ['id' => $id])
);
}
}
});
}
/**
* 검증 오류 메시지 커스터마이징
*
@@ -1,50 +0,0 @@
<?php
namespace App\Http\Requests\MailSendLog;
use App\Models\MailSendLog;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* 메일 발송 이력 일괄 삭제 요청을 검증합니다.
*/
class MailSendLogBulkDeleteRequest extends FormRequest
{
/**
* 요청 권한을 확인합니다.
*
* @return bool 항상 true (권한은 permission 미들웨어에서 처리)
*/
public function authorize(): bool
{
return true;
}
/**
* 유효성 검증 규칙을 반환합니다.
*
* @return array<string, mixed> 검증 규칙
*/
public function rules(): array
{
return [
'ids' => ['required', 'array', 'min:1'],
'ids.*' => ['required', 'integer', Rule::exists(MailSendLog::class, 'id')],
];
}
/**
* 사용자 정의 유효성 검증 메시지를 반환합니다.
*
* @return array<string, string> 메시지
*/
public function messages(): array
{
return [
'ids.required' => __('mail_send_log.validation.ids_required'),
'ids.min' => __('mail_send_log.validation.ids_min'),
'ids.*.exists' => __('mail_send_log.validation.id_not_found'),
];
}
}
@@ -1,31 +0,0 @@
<?php
namespace App\Http\Requests\MailSendLog;
use Illuminate\Foundation\Http\FormRequest;
/**
* 메일 발송 이력 삭제 요청을 검증합니다.
*/
class MailSendLogDeleteRequest extends FormRequest
{
/**
* 요청 권한을 확인합니다.
*
* @return bool 항상 true (권한은 permission 미들웨어에서 처리)
*/
public function authorize(): bool
{
return true;
}
/**
* 유효성 검증 규칙을 반환합니다.
*
* @return array<string, mixed> 검증 규칙
*/
public function rules(): array
{
return [];
}
}
@@ -1,67 +0,0 @@
<?php
namespace App\Http\Requests\MailSendLog;
use App\Enums\ExtensionOwnerType;
use App\Enums\MailSendStatus;
use App\Extension\HookManager;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* 메일 발송 이력 목록 조회 요청을 검증합니다.
*/
class MailSendLogIndexRequest extends FormRequest
{
/**
* 요청 권한을 확인합니다.
*
* @return bool 항상 true (권한은 permission 미들웨어에서 처리)
*/
public function authorize(): bool
{
return true;
}
/**
* 유효성 검증 규칙을 반환합니다.
*
* @return array<string, mixed> 검증 규칙
*/
public function rules(): array
{
$rules = [
'extension_type' => ['nullable', 'array'],
'extension_type.*' => ['string', Rule::in(ExtensionOwnerType::values())],
'extension_identifier' => ['nullable', 'array'],
'extension_identifier.*' => ['string', 'max:100'],
'template_type' => ['nullable', 'string', 'max:100'],
'status' => ['nullable', 'array'],
'status.*' => ['string', Rule::in(array_column(MailSendStatus::cases(), 'value'))],
'search' => ['nullable', 'string', 'max:255'],
'search_type' => ['nullable', 'string', Rule::in(['all', 'recipient_email', 'recipient_name', 'subject'])],
'date_from' => ['nullable', 'date'],
'date_to' => ['nullable', 'date', 'after_or_equal:date_from'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
'sort_by' => ['nullable', 'string', Rule::in(['sent_at', 'recipient_email', 'subject', 'extension_type', 'status'])],
'sort_order' => ['nullable', 'string', Rule::in(['asc', 'desc'])],
];
return HookManager::applyFilters('core.mail_send_log.index_validation_rules', $rules, $this);
}
/**
* 사용자 정의 유효성 검증 메시지를 반환합니다.
*
* @return array<string, string> 메시지
*/
public function messages(): array
{
return [
'status.in' => __('mail_send_log.validation.status_invalid'),
'date_to.after_or_equal' => __('mail_send_log.validation.date_range_invalid'),
'per_page.min' => __('mail_send_log.validation.per_page_min'),
'per_page.max' => __('mail_send_log.validation.per_page_max'),
];
}
}
@@ -1,54 +0,0 @@
<?php
namespace App\Http\Requests\MailTemplate;
use App\Extension\HookManager;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
/**
* 메일 템플릿 목록 조회 요청을 검증합니다.
*/
class MailTemplateIndexRequest extends FormRequest
{
/**
* 요청 권한을 확인합니다.
*
* @return bool 항상 true (권한은 permission 미들웨어에서 처리)
*/
public function authorize(): bool
{
return true;
}
/**
* 유효성 검증 규칙을 반환합니다.
*
* @return array<string, mixed> 검증 규칙
*/
public function rules(): array
{
$rules = [
'search' => ['nullable', 'string', 'max:255'],
'search_type' => ['nullable', 'string', Rule::in(['all', 'subject', 'body'])],
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
'sort_by' => ['nullable', 'string', Rule::in(['id', 'type', 'is_active', 'created_at', 'updated_at'])],
'sort_order' => ['nullable', 'string', Rule::in(['asc', 'desc'])],
];
return HookManager::applyFilters('core.mail_template.index_validation_rules', $rules, $this);
}
/**
* 사용자 정의 유효성 검증 메시지를 반환합니다.
*
* @return array<string, string> 메시지
*/
public function messages(): array
{
return [
'per_page.min' => __('mail_template.validation.per_page_min'),
'per_page.max' => __('mail_template.validation.per_page_max'),
];
}
}
@@ -1,52 +0,0 @@
<?php
namespace App\Http\Requests\MailTemplate;
use App\Extension\HookManager;
use Illuminate\Foundation\Http\FormRequest;
/**
* 메일 템플릿 미리보기 요청을 검증합니다.
*/
class MailTemplatePreviewRequest extends FormRequest
{
/**
* 요청 권한을 확인합니다.
*
* @return bool 항상 true (권한은 permission 미들웨어에서 처리)
*/
public function authorize(): bool
{
return true;
}
/**
* 유효성 검증 규칙을 반환합니다.
*
* @return array<string, mixed> 검증 규칙
*/
public function rules(): array
{
$rules = [
'subject' => ['required', 'string', 'max:500'],
'body' => ['required', 'string', 'max:65535'],
'variables' => ['nullable', 'array'],
'variables.*.key' => ['required_with:variables', 'string', 'max:100'],
];
return HookManager::applyFilters('core.mail_template.preview_validation_rules', $rules, $this);
}
/**
* 사용자 정의 유효성 검증 메시지를 반환합니다.
*
* @return array<string, string> 메시지
*/
public function messages(): array
{
return [
'subject.required' => __('mail_template.validation.subject_required'),
'body.required' => __('mail_template.validation.body_required'),
];
}
}
@@ -1,52 +0,0 @@
<?php
namespace App\Http\Requests\MailTemplate;
use App\Extension\HookManager;
use App\Rules\LocaleRequiredTranslatable;
use Illuminate\Foundation\Http\FormRequest;
/**
* 메일 템플릿 수정 요청을 검증합니다.
*/
class UpdateMailTemplateRequest extends FormRequest
{
/**
* 요청 권한을 확인합니다.
*
* @return bool 항상 true (권한은 permission 미들웨어에서 처리)
*/
public function authorize(): bool
{
return true;
}
/**
* 유효성 검증 규칙을 반환합니다.
*
* @return array<string, mixed> 검증 규칙
*/
public function rules(): array
{
$rules = [
'subject' => ['required', 'array', new LocaleRequiredTranslatable(maxLength: 500)],
'body' => ['required', 'array', new LocaleRequiredTranslatable(maxLength: 65535)],
'is_active' => ['sometimes', 'boolean'],
];
return HookManager::applyFilters('core.mail_template.update_validation_rules', $rules, $this);
}
/**
* 사용자 정의 유효성 검증 메시지를 반환합니다.
*
* @return array<string, string> 메시지
*/
public function messages(): array
{
return [
'subject.required' => __('mail_template.validation.subject_required'),
'body.required' => __('mail_template.validation.body_required'),
];
}
}
@@ -25,6 +25,7 @@ class InstallModuleRequest extends FormRequest
{
$rules = [
'module_name' => ['required', 'string', 'max:255', new ValidExtensionIdentifier],
'vendor_mode' => ['nullable', 'string', 'in:auto,composer,bundled'],
];
// 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공
@@ -0,0 +1,53 @@
<?php
namespace App\Http\Requests\Module;
use App\Extension\HookManager;
use Illuminate\Foundation\Http\FormRequest;
/**
* 모듈 업데이트 실행 요청 검증
*
* layout_strategy: 레이아웃 전략 (overwrite 또는 keep)
* vendor_mode: Vendor 설치 모드 (auto, composer, bundled)
*/
class PerformModuleUpdateRequest extends FormRequest
{
/**
* 사용자가 이 요청을 수행할 권한이 있는지 확인
*
* 권한 체크는 라우트의 permission 미들웨어에서 수행됩니다.
*/
public function authorize(): bool
{
return true;
}
/**
* 요청에 적용할 검증 규칙
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$rules = [
'layout_strategy' => ['nullable', 'string', 'in:overwrite,keep'],
'vendor_mode' => ['nullable', 'string', 'in:auto,composer,bundled'],
];
return HookManager::applyFilters('core.module.perform_update_rules', $rules);
}
/**
* 검증 오류 메시지 커스터마이징
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'layout_strategy.in' => __('modules.errors.invalid_layout_strategy'),
'vendor_mode.in' => __('modules.errors.invalid_vendor_mode'),
];
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Http\Requests\Notification;
use App\Extension\HookManager;
use Illuminate\Foundation\Http\FormRequest;
class NotificationBatchReadRequest extends FormRequest
{
/**
* 권한 확인 (미들웨어에서 처리).
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙을 반환합니다.
*/
public function rules(): array
{
$rules = [
'ids' => ['required', 'array', 'min:1', 'max:100'],
'ids.*' => ['required', 'string'],
];
return HookManager::applyFilters(
'core.notification.filter_batch_read_rules',
$rules
);
}
}
@@ -0,0 +1,35 @@
<?php
namespace App\Http\Requests\Notification;
use App\Extension\HookManager;
use Illuminate\Foundation\Http\FormRequest;
class NotificationIndexRequest extends FormRequest
{
/**
* 권한 확인 (미들웨어에서 처리).
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙을 반환합니다.
*/
public function rules(): array
{
$rules = [
'read' => ['nullable', 'string', 'in:unread,read,all'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
'page' => ['nullable', 'integer', 'min:1'],
'sort_order' => ['nullable', 'string', 'in:asc,desc'],
];
return HookManager::applyFilters(
'core.notification.filter_index_rules',
$rules
);
}
}
@@ -0,0 +1,56 @@
<?php
namespace App\Http\Requests\NotificationDefinition;
use App\Extension\HookManager;
use Illuminate\Foundation\Http\FormRequest;
class NotificationDefinitionIndexRequest extends FormRequest
{
/**
* 권한 확인 (미들웨어에서 처리).
*
* @return bool
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙을 반환합니다.
*
* @return array
*/
public function rules(): array
{
$rules = [
'search' => ['nullable', 'string', 'max:255'],
'extension_type' => ['nullable', 'string', 'in:core,module,plugin'],
'extension_identifier' => ['nullable', 'string', 'max:100'],
'channel' => ['nullable', 'string', 'max:50'],
'is_active' => ['nullable', 'boolean'],
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
'sort_by' => ['nullable', 'string', 'in:id,type,extension_type,is_active,created_at,updated_at'],
'sort_order' => ['nullable', 'string', 'in:asc,desc'],
];
return HookManager::applyFilters(
'core.notification_definition.filter_index_rules',
$rules
);
}
/**
* 검증 메시지를 반환합니다.
*
* @return array
*/
public function messages(): array
{
return [
'per_page.min' => __('validation.min.numeric', ['attribute' => 'per_page', 'min' => 1]),
'per_page.max' => __('validation.max.numeric', ['attribute' => 'per_page', 'max' => 100]),
];
}
}
@@ -0,0 +1,41 @@
<?php
namespace App\Http\Requests\NotificationDefinition;
use App\Extension\HookManager;
use Illuminate\Foundation\Http\FormRequest;
class UpdateNotificationDefinitionRequest extends FormRequest
{
/**
* 권한 확인 (미들웨어에서 처리).
*
* @return bool
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙을 반환합니다.
*
* @return array
*/
public function rules(): array
{
$rules = [
'channels' => ['sometimes', 'array', 'min:1'],
'channels.*' => ['string', 'max:50'],
'hooks' => ['sometimes', 'array'],
'hooks.*' => ['string', 'max:255'],
'is_active' => ['sometimes', 'boolean'],
];
return HookManager::applyFilters(
'core.notification_definition.filter_update_rules',
$rules,
$this->route('definition')
);
}
}
@@ -0,0 +1,44 @@
<?php
namespace App\Http\Requests\NotificationLog;
use Illuminate\Foundation\Http\FormRequest;
class NotificationLogBulkDeleteRequest extends FormRequest
{
/**
* 권한 확인 (미들웨어에서 처리).
*
* @return bool
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙을 반환합니다.
*
* @return array
*/
public function rules(): array
{
return [
'ids' => ['required', 'array', 'min:1'],
'ids.*' => ['integer'],
];
}
/**
* 검증 메시지를 반환합니다.
*
* @return array
*/
public function messages(): array
{
return [
'ids.required' => __('notification_log.no_items_selected'),
'ids.min' => __('notification_log.no_items_selected'),
];
}
}
@@ -0,0 +1,28 @@
<?php
namespace App\Http\Requests\NotificationLog;
use Illuminate\Foundation\Http\FormRequest;
class NotificationLogDeleteRequest extends FormRequest
{
/**
* 권한 확인 (미들웨어에서 처리).
*
* @return bool
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙을 반환합니다.
*
* @return array
*/
public function rules(): array
{
return [];
}
}
@@ -0,0 +1,44 @@
<?php
namespace App\Http\Requests\NotificationLog;
use App\Enums\NotificationLogStatus;
use App\Extension\HookManager;
use App\Models\User;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class NotificationLogIndexRequest extends FormRequest
{
/**
* 권한 확인 (미들웨어에서 처리).
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙을 반환합니다.
*/
public function rules(): array
{
$rules = [
'sender_user_id' => ['nullable', 'integer', Rule::exists(User::class, 'id')],
'recipient_user_id' => ['nullable', 'integer', Rule::exists(User::class, 'id')],
'search' => ['nullable', 'string', 'max:255'],
'channel' => ['nullable', 'string', 'max:50'],
'notification_type' => ['nullable', 'string', 'max:100'],
'extension_type' => ['nullable', 'string', 'in:core,module,plugin'],
'status' => ['nullable', 'string', Rule::enum(NotificationLogStatus::class)],
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
'sort_by' => ['nullable', 'string', 'in:id,channel,notification_type,status,sent_at,created_at'],
'sort_order' => ['nullable', 'string', 'in:asc,desc'],
];
return HookManager::applyFilters(
'core.notification_log.filter_index_rules',
$rules
);
}
}
@@ -0,0 +1,50 @@
<?php
namespace App\Http\Requests\NotificationTemplate;
use App\Models\NotificationDefinition;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class PreviewNotificationTemplateRequest extends FormRequest
{
/**
* 권한 확인 (미들웨어에서 처리).
*
* @return bool
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙을 반환합니다.
*
* @return array
*/
public function rules(): array
{
return [
'definition_id' => ['required', 'integer', Rule::exists(NotificationDefinition::class, 'id')],
'subject' => ['required', 'array'],
'body' => ['required', 'array'],
'locale' => ['sometimes', 'string', 'max:10'],
];
}
/**
* 검증 메시지를 반환합니다.
*
* @return array
*/
public function messages(): array
{
return [
'definition_id.required' => __('validation.required', ['attribute' => __('notification.definition')]),
'definition_id.exists' => __('validation.exists', ['attribute' => __('notification.definition')]),
'subject.required' => __('validation.required', ['attribute' => __('notification.subject')]),
'body.required' => __('validation.required', ['attribute' => __('notification.body')]),
];
}
}
@@ -0,0 +1,59 @@
<?php
namespace App\Http\Requests\NotificationTemplate;
use App\Extension\HookManager;
use App\Rules\LocaleRequiredTranslatable;
use Illuminate\Foundation\Http\FormRequest;
class UpdateNotificationTemplateRequest extends FormRequest
{
/**
* 권한 확인 (미들웨어에서 처리).
*
* @return bool
*/
public function authorize(): bool
{
return true;
}
/**
* 검증 규칙을 반환합니다.
*
* @return array
*/
public function rules(): array
{
$rules = [
'subject' => ['required', 'array', new LocaleRequiredTranslatable(maxLength: 500)],
'body' => ['required', 'array', new LocaleRequiredTranslatable(maxLength: 65535)],
'click_url' => ['nullable', 'string', 'max:500'],
'recipients' => ['sometimes', 'nullable', 'array'],
'recipients.*.type' => ['required', 'string', 'in:trigger_user,related_user,role,specific_users'],
'recipients.*.value' => ['nullable'],
'recipients.*.relation' => ['nullable', 'string', 'max:100'],
'recipients.*.exclude_trigger_user' => ['nullable', 'boolean'],
'is_active' => ['sometimes', 'boolean'],
];
return HookManager::applyFilters(
'core.notification_template.filter_update_rules',
$rules,
$this->route('template')
);
}
/**
* 검증 메시지를 반환합니다.
*
* @return array
*/
public function messages(): array
{
return [
'subject.required' => __('validation.required', ['attribute' => __('notification.subject')]),
'body.required' => __('validation.required', ['attribute' => __('notification.body')]),
];
}
}
@@ -32,6 +32,7 @@ class InstallPluginRequest extends FormRequest
{
$rules = [
'plugin_name' => ['required', 'string', 'max:255', new ValidExtensionIdentifier],
'vendor_mode' => ['nullable', 'string', 'in:auto,composer,bundled'],
];
// 모듈/플러그인이 validation rules를 동적으로 추가할 수 있도록 훅 제공
@@ -0,0 +1,53 @@
<?php
namespace App\Http\Requests\Plugin;
use App\Extension\HookManager;
use Illuminate\Foundation\Http\FormRequest;
/**
* 플러그인 업데이트 실행 요청 검증
*
* layout_strategy: 레이아웃 전략 (overwrite 또는 keep)
* vendor_mode: Vendor 설치 모드 (auto, composer, bundled)
*/
class PerformPluginUpdateRequest extends FormRequest
{
/**
* 사용자가 이 요청을 수행할 권한이 있는지 확인
*
* 권한 체크는 라우트의 permission 미들웨어에서 수행됩니다.
*/
public function authorize(): bool
{
return true;
}
/**
* 요청에 적용할 검증 규칙
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$rules = [
'layout_strategy' => ['nullable', 'string', 'in:overwrite,keep'],
'vendor_mode' => ['nullable', 'string', 'in:auto,composer,bundled'],
];
return HookManager::applyFilters('core.plugin.perform_update_rules', $rules);
}
/**
* 검증 오류 메시지 커스터마이징
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'layout_strategy.in' => __('plugins.errors.invalid_layout_strategy'),
'vendor_mode.in' => __('plugins.errors.invalid_vendor_mode'),
];
}
}
+2 -1
View File
@@ -30,7 +30,7 @@ class SearchRequest extends FormRequest
{
$rules = [
// 기본 검색 파라미터
'q' => ['nullable', 'string', 'max:200'],
'q' => ['nullable', 'string', 'min:2', 'max:200'],
'type' => ['nullable', 'string'],
'sort' => ['nullable', 'string'],
'page' => ['nullable', 'integer', 'min:1'],
@@ -50,6 +50,7 @@ class SearchRequest extends FormRequest
public function messages(): array
{
return [
'q.min' => __('search.validation.q_min'),
'q.max' => __('search.validation.q_max'),
'page.integer' => __('search.validation.page_integer'),
'page.min' => __('search.validation.page_min'),
@@ -4,6 +4,7 @@ namespace App\Http\Requests\Settings;
use App\Extension\HookManager;
use App\Models\Attachment;
use App\Search\Engines\DatabaseFulltextEngine;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@@ -98,6 +99,41 @@ class SaveSettingsRequest extends FormRequest
]),
]);
}
// GeoIP 토글 필드: 신규 필드라 초기값이 settings에 없을 수 있음.
// Toggle 컴포넌트가 undefined → true 전환 시 비표준 타입을 보낼 수 있으므로
// boolean으로 명시 캐스팅하여 Laravel boolean 검증 호환 보장.
$this->castAdvancedBooleanFields([
'advanced.geoip_enabled',
'advanced.geoip_auto_update_enabled',
]);
}
/**
* advanced 탭의 boolean 필드를 PHP boolean으로 캐스팅합니다.
*
* Toggle 컴포넌트가 초기값 미존재(undefined) 상태에서 전환 시
* 비표준 타입(문자열 "true", null 등)을 전송할 수 있으므로
* prepareForValidation 단계에서 명시 캐스팅합니다.
*
* @param array<int, string> $fields dot-notation 필드 경로 배열
*/
private function castAdvancedBooleanFields(array $fields): void
{
$advanced = $this->input('advanced', []);
$changed = false;
foreach ($fields as $dotPath) {
$key = str_replace('advanced.', '', $dotPath);
if (array_key_exists($key, $advanced)) {
$advanced[$key] = filter_var($advanced[$key], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE) ?? false;
$changed = true;
}
}
if ($changed) {
$this->merge(['advanced' => $advanced]);
}
}
/**
@@ -114,7 +150,7 @@ class SaveSettingsRequest extends FormRequest
$rules = [
// 탭 식별자
'_tab' => ['nullable', 'string', Rule::in(['general', 'mail', 'upload', 'seo', 'security', 'drivers', 'advanced'])],
'_tab' => ['nullable', 'string', Rule::in(['general', 'mail', 'upload', 'seo', 'security', 'drivers', 'advanced', 'notifications'])],
// 각 탭의 컨테이너
'general' => ['sometimes', 'array'],
@@ -124,13 +160,18 @@ class SaveSettingsRequest extends FormRequest
'security' => ['sometimes', 'array'],
'drivers' => ['sometimes', 'array'],
'advanced' => ['sometimes', 'array'],
'notifications' => ['sometimes', 'array'],
'notifications.channels' => ['sometimes', 'array'],
'notifications.channels.*.id' => ['required_with:notifications.channels', 'string', 'max:50'],
'notifications.channels.*.is_active' => ['required_with:notifications.channels', 'boolean'],
'notifications.channels.*.sort_order' => ['nullable', 'integer', 'min:0'],
// 일반 설정
'general.site_name' => $this->getTabRules($tab, 'general', 'string|max:100'),
'general.site_url' => $this->getTabRules($tab, 'general', 'url|max:255'),
'general.site_description' => ['nullable', 'string', 'max:500'],
'general.admin_email' => $this->getTabRules($tab, 'general', 'email|max:255'),
'general.timezone' => $this->getTabRules($tab, 'general', [Rule::in(config('app.supported_timezones', ['Asia/Seoul', 'UTC']))]),
'general.timezone' => $this->getTabRules($tab, 'general', ['timezone']),
'general.language' => $this->getTabRules($tab, 'general', [Rule::in(self::SUPPORTED_LANGUAGES)]),
'general.currency' => ['nullable', 'string', 'max:10'],
'general.maintenance_mode' => ['nullable', 'boolean'],
@@ -201,6 +242,13 @@ class SaveSettingsRequest extends FormRequest
'advanced.core_update_github_url' => ['nullable', 'url', 'max:500'],
'advanced.core_update_github_token' => ['nullable', 'string', 'max:500'],
// GeoIP 설정 (advanced 탭)
// 마스터 토글 OFF 시 하위 필드(license_key, auto_update)는 collapse 되어 미전송됨
// → nullable 필수 (기존 cache/debug 필드와 달리 조건부 렌더링 내부에 있음)
'advanced.geoip_enabled' => ['nullable', 'boolean'],
'advanced.geoip_license_key' => ['nullable', 'string', 'max:200', 'regex:/^[A-Za-z0-9_]+$/'],
'advanced.geoip_auto_update_enabled' => ['nullable', 'boolean'],
// 드라이버 설정 (drivers 탭)
'drivers.storage_driver' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_STORAGE_DRIVERS)]),
'drivers.s3_bucket' => ['nullable', 'string', 'max:255'],
@@ -219,10 +267,16 @@ class SaveSettingsRequest extends FormRequest
'drivers.session_lifetime' => ['nullable', 'integer', 'min:1', 'max:43200'], // 1분 ~ 30일
'drivers.queue_driver' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_QUEUE_DRIVERS)]),
'drivers.websocket_enabled' => ['nullable', 'boolean'],
'drivers.websocket_app_key' => ['nullable', 'string', 'max:255'],
'drivers.websocket_app_id' => [Rule::requiredIf(fn () => $this->boolean('drivers.websocket_enabled')), 'nullable', 'string', 'max:255'],
'drivers.websocket_app_key' => [Rule::requiredIf(fn () => $this->boolean('drivers.websocket_enabled')), 'nullable', 'string', 'max:255'],
'drivers.websocket_app_secret' => [Rule::requiredIf(fn () => $this->boolean('drivers.websocket_enabled')), 'nullable', 'string', 'max:255'],
'drivers.websocket_host' => ['nullable', 'string', 'max:255'],
'drivers.websocket_port' => ['nullable', 'integer', 'min:1', 'max:65535'],
'drivers.websocket_scheme' => ['nullable', Rule::in(self::SUPPORTED_WEBSOCKET_SCHEMES)],
'drivers.websocket_verify_ssl' => ['nullable', 'boolean'],
'drivers.websocket_server_host' => ['nullable', 'string', 'max:255'],
'drivers.websocket_server_port' => ['nullable', 'integer', 'min:1', 'max:65535'],
'drivers.websocket_server_scheme' => ['nullable', Rule::in(self::SUPPORTED_WEBSOCKET_SCHEMES)],
'drivers.search_engine_driver' => ['nullable', Rule::in($this->getSupportedSearchEngineDrivers())],
'drivers.log_driver' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_LOG_DRIVERS)]),
'drivers.log_level' => $this->getTabRules($tab, 'drivers', [Rule::in(self::SUPPORTED_LOG_LEVELS)]),
@@ -366,7 +420,7 @@ class SaveSettingsRequest extends FormRequest
{
$drivers = HookManager::applyFilters(
'core.search.engine_drivers',
['mysql-fulltext' => \App\Search\Engines\DatabaseFulltextEngine::class]
['mysql-fulltext' => DatabaseFulltextEngine::class]
);
return array_keys($drivers);
@@ -535,12 +589,23 @@ class SaveSettingsRequest extends FormRequest
'drivers.queue_driver.required' => __('validation.settings.queue_driver_required'),
'drivers.queue_driver.in' => __('validation.settings.queue_driver_invalid'),
'drivers.websocket_enabled.boolean' => __('validation.settings.websocket_enabled_boolean'),
'drivers.websocket_app_id.required' => __('validation.settings.websocket_app_id_required'),
'drivers.websocket_app_id.max' => __('validation.settings.websocket_app_id_max'),
'drivers.websocket_app_key.required' => __('validation.settings.websocket_app_key_required'),
'drivers.websocket_app_key.max' => __('validation.settings.websocket_app_key_max'),
'drivers.websocket_app_secret.required' => __('validation.settings.websocket_app_secret_required'),
'drivers.websocket_app_secret.max' => __('validation.settings.websocket_app_secret_max'),
'drivers.websocket_host.max' => __('validation.settings.websocket_host_max'),
'drivers.websocket_port.integer' => __('validation.settings.websocket_port_integer'),
'drivers.websocket_port.min' => __('validation.settings.websocket_port_min'),
'drivers.websocket_port.max' => __('validation.settings.websocket_port_max'),
'drivers.websocket_scheme.in' => __('validation.settings.websocket_scheme_invalid'),
'drivers.websocket_verify_ssl.boolean' => __('validation.settings.websocket_verify_ssl_boolean'),
'drivers.websocket_server_host.max' => __('validation.settings.websocket_server_host_max'),
'drivers.websocket_server_port.integer' => __('validation.settings.websocket_server_port_integer'),
'drivers.websocket_server_port.min' => __('validation.settings.websocket_server_port_min'),
'drivers.websocket_server_port.max' => __('validation.settings.websocket_server_port_max'),
'drivers.websocket_server_scheme.in' => __('validation.settings.websocket_server_scheme_invalid'),
'drivers.search_engine_driver.in' => __('validation.settings.search_engine_driver_invalid'),
'drivers.log_driver.required' => __('validation.settings.log_driver_required'),
'drivers.log_driver.in' => __('validation.settings.log_driver_invalid'),
+1 -1
View File
@@ -36,7 +36,7 @@ class CreateUserRequest extends FormRequest
'password' => ['required', 'confirmed', Password::min(8)],
'language' => 'nullable|string|in:'.$supportedLocales,
'country' => 'nullable|string|size:2|alpha',
'timezone' => ['nullable', 'string', Rule::in(config('app.supported_timezones', ['Asia/Seoul']))],
'timezone' => ['nullable', 'string', 'timezone'],
'status' => 'nullable|string|in:active,inactive,blocked,withdrawn',
// 연락처 정보
@@ -44,7 +44,7 @@ class UpdateProfileRequest extends FormRequest
'current_password' => ['required_with:password', 'current_password'],
'language' => ['nullable', 'string', Rule::in($supportedLocales)],
'country' => ['nullable', 'string', 'size:2', 'alpha'],
'timezone' => ['nullable', 'string', Rule::in(config('app.supported_timezones', ['Asia/Seoul']))],
'timezone' => ['nullable', 'string', 'timezone'],
// 연락처 정보
'homepage' => ['nullable', 'url', 'max:255'],
+1 -1
View File
@@ -57,7 +57,7 @@ class UpdateUserRequest extends FormRequest
'password' => ['nullable', 'confirmed', Password::min(8)],
'language' => 'nullable|string|in:'.$supportedLocales,
'country' => 'nullable|string|size:2|alpha',
'timezone' => ['nullable', 'string', Rule::in(config('app.supported_timezones', ['Asia/Seoul']))],
'timezone' => ['nullable', 'string', 'timezone'],
'status' => 'nullable|string|in:active,inactive,blocked,withdrawn',
// 연락처 정보
+13 -4
View File
@@ -2,9 +2,7 @@
namespace App\Http\Resources;
use App\Http\Resources\Traits\HasRowNumber;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
use Illuminate\Pagination\LengthAwarePaginator;
/**
@@ -12,9 +10,17 @@ use Illuminate\Pagination\LengthAwarePaginator;
*
* 활동 로그 목록을 페이지네이션과 함께 반환합니다.
*/
class ActivityLogCollection extends ResourceCollection
class ActivityLogCollection extends BaseApiCollection
{
use HasRowNumber;
/**
* {@inheritDoc}
*/
protected function abilityMap(): array
{
return [
'can_delete' => 'core.activities.delete',
];
}
/**
* 활동 로그 컬렉션을 배열로 변환합니다.
@@ -24,6 +30,8 @@ class ActivityLogCollection extends ResourceCollection
*/
public function toArray(Request $request): array
{
$abilities = $this->resolveCollectionAbilities($request);
return [
'data' => $this->mapWithRowNumber(function ($activityLog) {
return (new ActivityLogResource($activityLog))->toArray(request());
@@ -37,6 +45,7 @@ class ActivityLogCollection extends ResourceCollection
'to' => $this->resource->lastItem(),
'has_more_pages' => $this->resource->hasMorePages(),
] : null,
...($abilities ? ['abilities' => $abilities] : []),
];
}
}
+56
View File
@@ -0,0 +1,56 @@
<?php
namespace App\Http\Resources;
use App\Helpers\PermissionHelper;
use App\Http\Resources\Traits\HasRowNumber;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
/**
* API 컬렉션 리소스 기본 클래스
*
* 모든 API Collection은 이 클래스를 상속해야 합니다.
* - HasRowNumber: 순번 부여
* - abilityMap(): 컬렉션 레벨 abilities (페이지 버튼 제어)
*/
abstract class BaseApiCollection extends ResourceCollection
{
use HasRowNumber;
/**
* 컬렉션 레벨 능력(can_*) 매핑을 반환합니다.
*
* 페이지 레벨 버튼 제어용 (생성, 일괄삭제 등).
* abilities가 불필요한 컬렉션은 오버라이드하지 않습니다.
*
* @return array<string, string> ['can_delete' => 'permission.identifier', ...]
*/
protected function abilityMap(): array
{
return [];
}
/**
* 컬렉션 레벨 abilities를 해석합니다.
*
* @param Request $request HTTP 요청 객체
* @return array<string, bool>
*/
public function resolveCollectionAbilities(Request $request): array
{
$map = $this->abilityMap();
if (empty($map)) {
return [];
}
$user = $request->user();
$abilities = [];
foreach ($map as $key => $permission) {
$abilities[$key] = PermissionHelper::check($permission, $user);
}
return $abilities;
}
}
@@ -1,42 +0,0 @@
<?php
namespace App\Http\Resources;
use App\Http\Resources\Traits\HasRowNumber;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
use Illuminate\Pagination\LengthAwarePaginator;
/**
* 메일 발송 이력 컬렉션 리소스
*
* 발송 이력 목록을 페이지네이션과 함께 반환합니다.
*/
class MailSendLogCollection extends ResourceCollection
{
use HasRowNumber;
/**
* 메일 발송 이력 컬렉션을 배열로 변환합니다.
*
* @param Request $request HTTP 요청 객체
* @return array<int|string, mixed> 변환된 컬렉션 배열
*/
public function toArray(Request $request): array
{
return [
'data' => $this->mapWithRowNumber(function ($mailSendLog) {
return (new MailSendLogResource($mailSendLog))->toArray(request());
}),
'pagination' => $this->resource instanceof LengthAwarePaginator ? [
'current_page' => $this->resource->currentPage(),
'last_page' => $this->resource->lastPage(),
'per_page' => $this->resource->perPage(),
'total' => $this->resource->total(),
'from' => $this->resource->firstItem(),
'to' => $this->resource->lastItem(),
'has_more_pages' => $this->resource->hasMorePages(),
] : null,
];
}
}
@@ -1,51 +0,0 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
/**
* 메일 발송 이력 API 리소스
*/
class MailSendLogResource extends BaseApiResource
{
/**
* 권한 매핑을 반환합니다.
*
* @return array<string, string> 능력명 => 권한 식별자 매핑
*/
protected function abilityMap(): array
{
return [
'can_delete' => 'core.mail-send-logs.delete',
];
}
/**
* 리소스를 배열로 변환합니다.
*
* @param Request $request HTTP 요청 객체
* @return array<string, mixed> 변환된 배열 데이터
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'sender_email' => $this->sender_email,
'sender_name' => $this->sender_name,
'recipient_email' => $this->recipient_email,
'recipient_name' => $this->recipient_name,
'subject' => $this->subject,
'body' => $this->body,
'template_type' => $this->template_type,
'extension_type' => $this->extension_type?->value,
'extension_identifier' => $this->extension_identifier,
'source' => $this->source,
'status' => $this->status,
'error_message' => $this->error_message,
'sent_at' => $this->formatDateTimeStringForUser($this->sent_at),
...$this->formatTimestamps(),
...$this->resourceMeta($request),
];
}
}
@@ -1,42 +0,0 @@
<?php
namespace App\Http\Resources;
use App\Http\Resources\Traits\HasRowNumber;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
use Illuminate\Pagination\LengthAwarePaginator;
/**
* 메일 템플릿 컬렉션 리소스
*
* 템플릿 목록을 페이지네이션과 함께 반환합니다.
*/
class MailTemplateCollection extends ResourceCollection
{
use HasRowNumber;
/**
* 메일 템플릿 컬렉션을 배열로 변환합니다.
*
* @param Request $request HTTP 요청 객체
* @return array<int|string, mixed> 변환된 컬렉션 배열
*/
public function toArray(Request $request): array
{
return [
'data' => $this->mapWithRowNumber(function ($mailTemplate) {
return (new MailTemplateResource($mailTemplate))->toArray(request());
}),
'pagination' => $this->resource instanceof LengthAwarePaginator ? [
'current_page' => $this->resource->currentPage(),
'last_page' => $this->resource->lastPage(),
'per_page' => $this->resource->perPage(),
'total' => $this->resource->total(),
'from' => $this->resource->firstItem(),
'to' => $this->resource->lastItem(),
'has_more_pages' => $this->resource->hasMorePages(),
] : null,
];
}
}
@@ -1,30 +0,0 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
class MailTemplateResource extends BaseApiResource
{
/**
* 리소스를 배열로 변환합니다.
*
* @param Request $request HTTP 요청
* @return array<string, mixed> 리소스 배열
*/
public function toArray(Request $request): array
{
return [
'id' => $this->getValue('id'),
'type' => $this->getValue('type'),
'subject' => $this->getValue('subject'),
'body' => $this->getValue('body'),
'variables' => $this->getValue('variables'),
'is_active' => (bool) $this->getValue('is_active'),
'is_default' => (bool) $this->getValue('is_default'),
'updated_by' => $this->updater?->uuid,
...$this->formatTimestamps(),
...$this->resourceMeta($request),
];
}
}
+1 -2
View File
@@ -4,9 +4,8 @@ namespace App\Http\Resources;
use App\Http\Resources\Traits\HasAbilityCheck;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
class MenuCollection extends ResourceCollection
class MenuCollection extends BaseApiCollection
{
use HasAbilityCheck;
+13 -2
View File
@@ -3,10 +3,21 @@
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
class ModuleCollection extends ResourceCollection
class ModuleCollection extends BaseApiCollection
{
/**
* {@inheritDoc}
*/
protected function abilityMap(): array
{
return [
'can_install' => 'core.modules.install',
'can_activate' => 'core.modules.activate',
'can_uninstall' => 'core.modules.uninstall',
];
}
/**
* 모듈 컬렉션을 배열로 변환합니다.
*
+1
View File
@@ -67,6 +67,7 @@ class ModuleResource extends BaseApiResource
'version' => $this->getValue('version'),
'description' => $this->getLocalizedField('description'),
'github_url' => $this->getValue('github_url'),
'requires_core' => $this->getValue('requires_core'),
'dependencies' => $this->getValue('dependencies', []),
'status' => $this->getValue('status'),
'is_installed' => $this->getValue('is_installed', false),
@@ -0,0 +1,46 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
class NotificationDefinitionCollection extends BaseApiCollection
{
/**
* {@inheritDoc}
*/
protected function abilityMap(): array
{
return [
'can_update' => 'core.settings.update',
];
}
/**
* 컬렉션을 배열로 변환합니다.
*
* @param Request $request
* @return array
*/
public function toArray(Request $request): array
{
$sortOrder = $request->input('sort_order', 'asc');
$abilities = $this->resolveCollectionAbilities($request);
return [
'data' => $this->mapWithRowNumber(function ($definition) use ($request) {
return (new NotificationDefinitionResource($definition))->toArray($request);
}, $sortOrder),
'pagination' => [
'current_page' => $this->currentPage(),
'last_page' => $this->lastPage(),
'per_page' => $this->perPage(),
'total' => $this->total(),
'from' => $this->firstItem(),
'to' => $this->lastItem(),
'has_more_pages' => $this->hasMorePages(),
],
...($abilities ? ['abilities' => $abilities] : []),
];
}
}
@@ -0,0 +1,48 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
class NotificationDefinitionResource extends BaseApiResource
{
/**
* {@inheritDoc}
*/
protected function abilityMap(): array
{
return [
'can_update' => 'core.settings.update',
'can_delete' => 'core.settings.update',
];
}
/**
* 리소스를 배열로 변환합니다.
*
* @param Request $request
* @return array
*/
public function toArray(Request $request): array
{
return [
'id' => $this->getValue('id'),
'type' => $this->getValue('type'),
'hook_prefix' => $this->getValue('hook_prefix'),
'extension_type' => $this->getValue('extension_type'),
'extension_identifier' => $this->getValue('extension_identifier'),
'name' => $this->getValue('name'),
'description' => $this->getValue('description'),
'variables' => $this->getValue('variables'),
'channels' => $this->getValue('channels'),
'hooks' => $this->getValue('hooks'),
'is_active' => (bool) $this->getValue('is_active'),
'is_default' => (bool) $this->getValue('is_default'),
'templates' => $this->relationLoaded('templates')
? NotificationTemplateResource::collection($this->templates)
: null,
...$this->formatTimestamps(),
...$this->resourceMeta($request),
];
}
}
@@ -0,0 +1,46 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
class NotificationLogCollection extends BaseApiCollection
{
/**
* {@inheritDoc}
*/
protected function abilityMap(): array
{
return [
'can_delete' => 'core.notification-logs.delete',
];
}
/**
* 컬렉션을 배열로 변환합니다.
*
* @param Request $request
* @return array
*/
public function toArray(Request $request): array
{
$sortOrder = $request->input('sort_order', 'desc');
$abilities = $this->resolveCollectionAbilities($request);
return [
'data' => $this->mapWithRowNumber(function ($log) use ($request) {
return (new NotificationLogResource($log))->toArray($request);
}, $sortOrder),
'pagination' => [
'current_page' => $this->resource->currentPage(),
'last_page' => $this->resource->lastPage(),
'per_page' => $this->resource->perPage(),
'total' => $this->resource->total(),
'from' => $this->resource->firstItem(),
'to' => $this->resource->lastItem(),
'has_more_pages' => $this->resource->hasMorePages(),
],
...($abilities ? ['abilities' => $abilities] : []),
];
}
}
@@ -0,0 +1,64 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
class NotificationLogResource extends BaseApiResource
{
/**
* 리소스를 배열로 변환합니다.
*/
public function toArray(Request $request): array
{
return [
'id' => $this->getValue('id'),
'channel' => $this->getValue('channel'),
'notification_type' => $this->getValue('notification_type'),
'extension_type' => $this->getValue('extension_type'),
'extension_identifier' => $this->getValue('extension_identifier'),
'recipient_user_id' => $this->getValue('recipient_user_id'),
'recipient_identifier' => $this->getValue('recipient_identifier'),
'recipient_name' => $this->getValue('recipient_name'),
'sender_user_id' => $this->getValue('sender_user_id'),
'sender' => $this->whenLoaded('senderUser', function () {
$user = $this->resource->senderUser;
return $user ? [
'uuid' => $user->uuid,
'name' => $user->name,
'email' => $user->email,
] : null;
}),
'recipient' => $this->whenLoaded('recipientUser', function () {
$user = $this->resource->recipientUser;
return $user ? [
'uuid' => $user->uuid,
'name' => $user->name,
'email' => $user->email,
] : null;
}),
'subject' => $this->getValue('subject'),
'body' => $this->getValue('body'),
'status' => $this->getValue('status')?->value ?? $this->getValue('status'),
'error_message' => $this->getValue('error_message'),
'source' => $this->getValue('source'),
'sent_at' => $this->formatDateTimeStringForUser($this->resource->sent_at),
...$this->formatTimestamps(),
...$this->resourceMeta($request),
];
}
/**
* 리소스별 능력 매핑을 반환합니다.
*
* @return array<string, string>
*/
protected function abilityMap(): array
{
return [
'can_delete' => 'core.notification-logs.delete',
];
}
}
@@ -0,0 +1,73 @@
<?php
namespace App\Http\Resources;
use App\Models\Role;
use App\Models\User;
use Illuminate\Http\Request;
class NotificationTemplateResource extends BaseApiResource
{
/**
* {@inheritDoc}
*/
protected function abilityMap(): array
{
return [
'can_update' => 'core.settings.update',
'can_delete' => 'core.settings.update',
];
}
/**
* 리소스를 배열로 변환합니다.
*
* @param Request $request
* @return array
*/
public function toArray(Request $request): array
{
return [
'id' => $this->getValue('id'),
'definition_id' => $this->getValue('definition_id'),
'channel' => $this->getValue('channel'),
'subject' => $this->getValue('subject'),
'body' => $this->getValue('body'),
'click_url' => $this->getValue('click_url'),
'recipients' => $this->enrichRecipients($this->getValue('recipients') ?? []),
'is_active' => (bool) $this->getValue('is_active'),
'is_default' => (bool) $this->getValue('is_default'),
'user_overrides' => $this->getValue('user_overrides'),
'updated_by' => $this->resource->updater?->uuid ?? null,
...$this->formatTimestamps(),
...$this->resourceMeta($request),
];
}
/**
* 수신자 규칙에 표시용 이름을 추가합니다.
*
* @param array $recipients 수신자 규칙 배열
* @return array
*/
private function enrichRecipients(array $recipients): array
{
$locale = app()->getLocale();
return array_map(function (array $rule) use ($locale) {
if ($rule['type'] === 'role' && ! empty($rule['value'])) {
$role = Role::where('identifier', $rule['value'])->first();
$rule['display_name'] = $role
? ($role->name[$locale] ?? $role->name['ko'] ?? $rule['value'])
: $rule['value'];
}
if ($rule['type'] === 'specific_users' && ! empty($rule['value'])) {
$users = User::whereIn('uuid', $rule['value'])->pluck('name', 'uuid');
$rule['display_names'] = $users->values()->all();
}
return $rule;
}, $recipients);
}
}
+1 -2
View File
@@ -3,14 +3,13 @@
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
/**
* 플러그인 컬렉션 리소스
*
* 플러그인 목록을 다양한 형태로 변환하여 반환합니다.
*/
class PluginCollection extends ResourceCollection
class PluginCollection extends BaseApiCollection
{
/**
* 플러그인 컬렉션을 배열로 변환합니다.
+1
View File
@@ -94,6 +94,7 @@ class PluginResource extends BaseApiResource
'version' => $this->getValue('version', '1.0.0'),
'description' => $this->getLocalizedField('description'),
'github_url' => $this->getValue('github_url'),
'requires_core' => $this->getValue('requires_core'),
'dependencies' => $this->getValue('dependencies', []),
'status' => $this->getValue('status', 'uninstalled'),
'is_installed' => $this->getValue('is_installed', false),
+1 -2
View File
@@ -4,9 +4,8 @@ namespace App\Http\Resources;
use App\Http\Resources\Traits\HasAbilityCheck;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
class RoleCollection extends ResourceCollection
class RoleCollection extends BaseApiCollection
{
use HasAbilityCheck;
+1 -2
View File
@@ -3,10 +3,9 @@
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
use Illuminate\Pagination\LengthAwarePaginator;
class ScheduleCollection extends ResourceCollection
class ScheduleCollection extends BaseApiCollection
{
/**
* 스케줄 컬렉션을 배열로 변환합니다.
@@ -3,10 +3,9 @@
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
use Illuminate\Pagination\LengthAwarePaginator;
class ScheduleHistoryCollection extends ResourceCollection
class ScheduleHistoryCollection extends BaseApiCollection
{
/**
* 스케줄 실행 이력 컬렉션을 배열로 변환합니다.
+1 -2
View File
@@ -3,14 +3,13 @@
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
/**
* 템플릿 컬렉션 리소스
*
* 템플릿 목록을 다양한 형태로 변환하여 반환합니다.
*/
class TemplateCollection extends ResourceCollection
class TemplateCollection extends BaseApiCollection
{
/**
* 템플릿 컬렉션을 배열로 변환합니다.
+1
View File
@@ -83,6 +83,7 @@ class TemplateResource extends BaseApiResource
'description' => $this->getLocalizedField('description'),
'github_url' => $this->getValue('github_url'),
'github_changelog_url' => $this->getValue('github_changelog_url'),
'requires_core' => $this->getValue('requires_core'),
// pending/bundled 상태
'is_pending' => $this->getValue('is_pending', false),
'is_bundled' => $this->getValue('is_bundled', false),
+1 -4
View File
@@ -3,14 +3,11 @@
namespace App\Http\Resources;
use App\Http\Resources\Traits\HasAbilityCheck;
use App\Http\Resources\Traits\HasRowNumber;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
class UserCollection extends ResourceCollection
class UserCollection extends BaseApiCollection
{
use HasAbilityCheck;
use HasRowNumber;
/**
* 컬렉션 레벨 능력(can_*) 매핑을 반환합니다.
@@ -0,0 +1,43 @@
<?php
namespace App\Http\Resources;
use App\Contracts\Repositories\NotificationDefinitionRepositoryInterface;
use Illuminate\Http\Request;
class UserNotificationCollection extends BaseApiCollection
{
/**
* 컬렉션을 배열로 변환합니다.
*
* @param Request $request
* @return array
*/
public function toArray(Request $request): array
{
$sortOrder = $request->input('sort_order', 'desc');
// 알림 type 식별자 → 다국어 라벨 맵을 컬렉션 단위로 한 번만 로드 (N+1 회피)
$user = $request->user();
$typeLabelMap = app(NotificationDefinitionRepositoryInterface::class)
->getLabelMap($user?->locale);
return [
'data' => $this->mapWithRowNumber(function ($notification) use ($request, $typeLabelMap) {
return (new UserNotificationResource($notification))
->withTypeLabelMap($typeLabelMap)
->toArray($request);
}, $sortOrder),
'pagination' => [
'current_page' => $this->resource->currentPage(),
'last_page' => $this->resource->lastPage(),
'per_page' => $this->resource->perPage(),
'total' => $this->resource->total(),
'from' => $this->resource->firstItem(),
'to' => $this->resource->lastItem(),
'has_more_pages' => $this->resource->hasMorePages(),
],
];
}
}
@@ -0,0 +1,92 @@
<?php
namespace App\Http\Resources;
use App\Contracts\Repositories\NotificationDefinitionRepositoryInterface;
use Illuminate\Http\Request;
class UserNotificationResource extends BaseApiResource
{
/**
* type 식별자 → 다국어 라벨 맵 (Collection에서 사전 주입).
*
* Collection에서 일괄 응답 시 N+1을 회피하기 위해 한 번만 로드한 맵을 주입받습니다.
* 단일 Resource로 사용될 경우 null이며, toArray() 내부에서 즉시 조회합니다.
*
* @var array<string, string>|null
*/
protected ?array $typeLabelMap = null;
/**
* 사전 주입된 type 라벨 맵을 설정합니다.
*
* @param array<string, string> $map
* @return $this
*/
public function withTypeLabelMap(array $map): static
{
$this->typeLabelMap = $map;
return $this;
}
/**
* 리소스를 배열로 변환합니다.
*
* @param Request $request
* @return array
*/
public function toArray(Request $request): array
{
$data = $this->getValue('data') ?? [];
$user = $request->user();
$timezone = $user?->getTimezone() ?? config('app.default_user_timezone', 'Asia/Seoul');
$type = $data['type'] ?? $this->getValue('type');
$typeLabel = $this->resolveTypeLabel($type, $user?->locale);
// 알림 클릭 시 이동 URL — click_url(템플릿 패턴 기반) 우선, 없으면 action_url(메일 CTA) fallback
$innerData = $data['data'] ?? [];
$url = $data['click_url'] ?? $innerData['action_url'] ?? null;
return [
'id' => $this->getValue('id'),
'type' => $type,
'type_label' => $typeLabel,
'subject' => $data['subject'] ?? null,
'body' => $data['body'] ?? null,
'url' => $url,
'data' => $data,
'read_at' => $this->resource->read_at?->setTimezone($timezone)->format('Y-m-d H:i:s'),
'created_at' => $this->resource->created_at?->setTimezone($timezone)->format('Y-m-d H:i:s'),
];
}
/**
* type 식별자에 해당하는 다국어 라벨을 해석합니다.
*
* 우선순위:
* 1. Collection에서 사전 주입한 typeLabelMap
* 2. Repository에서 즉시 조회 (단일 Resource 사용 시)
* 3. 정의 미존재 시 type 식별자 그대로 반환 (UI에 raw 키 노출 방지 fallback)
*
* @param string|null $type
* @param string|null $locale
* @return string
*/
protected function resolveTypeLabel(?string $type, ?string $locale): string
{
if (! $type) {
return '';
}
if ($this->typeLabelMap !== null) {
return $this->typeLabelMap[$type] ?? '';
}
$repository = app(NotificationDefinitionRepositoryInterface::class);
$definition = $repository->getActiveByType($type);
return $definition?->getLocalizedName($locale) ?: '';
}
}