Files
Gnuboard7/app/Http/Controllers/Api/Admin/SettingsController.php
T
HeuJung d17ea28da5 fix(core,board,ecommerce,page,kginicis): 정책값 하드코딩·계약 분리 결함 일괄 수정
관리자 환경설정이 값을 저장은 하지만 실제 동작에는 닿지 않던 결함군을 정리했다.
설정 화면(min/max)·저장 검증(FormRequest)·런타임 적용(Service) 세 계층이 각자
리터럴을 들고 있어 서로 조용히 갈라지던 것이 공통 원인이다.

경계값 SSoT 도입:
- config/core.php `settings_limits` 를 단일 출처로 두고, FormRequest 는 이 값으로
 규칙을 만들고 화면은 설정 응답의 `_meta.limits` 로 같은 값을 받아 바인딩한다.
- 게시판·이커머스도 각 모듈 config 의 limits 를 동일 구조로 노출한다.

적용되지 않던 정책을 실제로 적용:
- 비밀번호 최소 길이·특수문자 필수(신규 지정 7개 경로. 로그인은 제외 — 정책을
 올렸을 때 기존 회원이 자기 계정에 접근조차 못 하는 데드락을 막기 위함)
- 계정 잠금 시간 0 = 무한대(전용 컬럼 분리. timestamp 상한과 NULL 규약 때문에
 기존 컬럼 재사용이 불가능하다) + 관리자 수동 해제 경로
- 업로드 최대 크기·허용 확장자, 게시판 첨부 개수·댓글 깊이, 페이지 첨부 정책,
 장바구니 수량·구매 한도, 마일리지 사용 한도, 주문 부가세

파생 결함:
- 큐 Job(sync 드라이버)이 진행 중인 요청의 Request 바인딩을 덮어써, 뒤이어 실행되는
 훅 리스너가 헤더를 잃고 조용히 건너뛰었다. Job 이 원 바인딩을 복원하도록 고치고,
 전송 헤더가 필요한 리스너는 동기 실행으로 선언한다.
- 확장 업데이트 실패 시 상태만 되돌리고 상태 캐시를 비우지 않아, 그 확장의 관리자
 화면만 캐시 TTL(기본 하루) 동안 404 로 남았다. 실패 경로에도 무효화를 대칭으로
 두고, 빈 목록 자체를 캐시하지 않는다.
- 글 작성 시 첨부 파일이 Service 로 전달되지 않아 검증·권한은 통과하고 첨부만 사라졌다.
- 환경설정 저장 실패 안내가 내부 설정 키를 그대로 노출했다(105개 항목에 라벨 부여).

재발 방지:
- audit 룰 3종(레이아웃 경계 리터럴 / FormRequest 경계 불일치 / Service 재클램프)
- 테스트 실행 범위 강제 훅(check-test-scope)
- 트러블슈팅 사례 2건(큐 Job 요청 오염 / 확장 상태 캐시 고착)

공개 이슈: gnuboard/g7
2026-07-30 07:52:24 +09:00

346 lines
12 KiB
PHP

<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Http\Requests\Settings\RegenerateAppKeyRequest;
use App\Http\Requests\Settings\RestoreSettingsRequest;
use App\Http\Requests\Settings\SaveSettingsRequest;
use App\Http\Requests\Settings\TestDriverConnectionRequest;
use App\Http\Requests\Settings\TestMailRequest;
use App\Http\Requests\Settings\UpdateSettingRequest;
use App\Http\Resources\SettingsResource;
use App\Services\DriverConnectionTester;
use App\Services\DriverRegistryService;
use App\Services\SettingsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
/**
* 관리자용 시스템 설정 컨트롤러
*
* 관리자가 시스템 설정을 관리할 수 있는 기능을 제공합니다.
*/
class SettingsController extends AdminBaseController
{
public function __construct(
private SettingsService $settingsService,
private DriverConnectionTester $driverConnectionTester,
private DriverRegistryService $driverRegistryService
) {
parent::__construct();
}
/**
* 모든 시스템 설정을 조회합니다.
*
* @return JsonResponse 시스템 설정 목록을 포함한 JSON 응답
*/
public function index(): JsonResponse
{
try {
$settings = $this->settingsService->getAllSettings();
$settings['available_drivers'] = $this->driverRegistryService->getAllAvailableDrivers();
$settings['_meta'] = ['limits' => config('core.settings_limits', [])];
return $this->success('settings.fetch_success',
(new SettingsResource($settings))->toArray(request())
);
} catch (\Exception $e) {
return $this->error('settings.fetch_failed', 500, $e->getMessage());
}
}
/**
* 여러 시스템 설정을 일괄 저장합니다.
*
* 저장 성공 시 전체 settings를 응답에 포함하여
* 프론트엔드에서 새로고침 없이 전역 상태를 업데이트할 수 있도록 합니다.
*
* @param SaveSettingsRequest $request 설정 저장 요청 데이터
* @return JsonResponse 저장 결과 JSON 응답
*/
public function store(SaveSettingsRequest $request): JsonResponse
{
try {
$result = $this->settingsService->saveSettings($request->validated());
if ($result) {
// 저장 후 전체 설정 반환 (관리자 UI 상태 업데이트용)
$allSettings = $this->settingsService->getAllSettings();
$allSettings['available_drivers'] = $this->driverRegistryService->getAllAvailableDrivers();
$allSettings['_meta'] = ['limits' => config('core.settings_limits', [])];
return $this->success('settings.save_success', [
'settings' => $allSettings,
]);
} else {
return $this->error('settings.save_failed');
}
} catch (ValidationException $e) {
return $this->error('settings.save_failed', 422, $e->errors());
} catch (\Exception $e) {
return $this->error('settings.save_error', 500, $e->getMessage());
}
}
/**
* 특정 키의 시스템 설정을 조회합니다.
*
* @param string $key 조회할 설정 키
* @return JsonResponse 설정 값을 포함한 JSON 응답
*/
public function show(string $key): JsonResponse
{
try {
$value = $this->settingsService->getSetting($key);
return $this->success('settings.fetch_success', [
'key' => $key,
'value' => $value,
]);
} catch (\Exception $e) {
return $this->error('settings.fetch_failed', 500, $e->getMessage());
}
}
/**
* 특정 키의 시스템 설정을 업데이트합니다.
*
* @param string $key 업데이트할 설정 키
* @param UpdateSettingRequest $request 설정 업데이트 요청 데이터
* @return JsonResponse 업데이트 결과 JSON 응답
*/
public function update(string $key, UpdateSettingRequest $request): JsonResponse
{
try {
$result = $this->settingsService->setSetting($key, $request->validated()['value']);
if ($result) {
return $this->success('settings.update_success');
} else {
return $this->error('settings.update_failed');
}
} catch (ValidationException $e) {
return $this->error('settings.update_failed', 422, $e->errors());
} catch (\Exception $e) {
return $this->error('settings.update_error', 500, $e->getMessage());
}
}
/**
* 시스템 환경 정보를 조회합니다.
*
* @return JsonResponse 시스템 정보를 포함한 JSON 응답
*/
public function systemInfo(): JsonResponse
{
try {
$systemInfo = $this->settingsService->getSystemInfo();
return $this->success('common.success', $systemInfo);
} catch (\Throwable $e) {
Log::error('시스템 정보 조회 실패', ['error' => $e->getMessage()]);
return $this->error('common.error_occurred', 500, $e->getMessage());
}
}
/**
* 시스템 캐시를 정리합니다.
*
* @return JsonResponse 캐시 정리 결과 JSON 응답
*/
public function clearCache(): JsonResponse
{
try {
$result = $this->settingsService->clearCache();
if ($result) {
return $this->success('settings.cache_clear_success');
} else {
return $this->error('settings.cache_clear_failed');
}
} catch (\Exception $e) {
return $this->error('settings.cache_clear_error', 500, $e->getMessage());
}
}
/**
* 시스템을 최적화합니다 (캐시 생성).
*
* @return JsonResponse 최적화 결과 JSON 응답
*/
public function optimizeSystem(): JsonResponse
{
try {
$result = $this->settingsService->optimizeSystem();
if ($result) {
return $this->success('settings.optimize_success');
} else {
return $this->error('settings.optimize_failed');
}
} catch (\Exception $e) {
return $this->error('settings.optimize_error', 500, $e->getMessage());
}
}
/**
* 데이터베이스를 백업합니다.
*
* @return JsonResponse 백업 결과 JSON 응답
*/
public function backupDatabase(): JsonResponse
{
try {
$result = $this->settingsService->backupDatabase();
if ($result) {
return $this->success('settings.backup_success');
} else {
return $this->error('settings.backup_failed');
}
} catch (\Exception $e) {
return $this->error('settings.backup_error', 500, $e->getMessage());
}
}
/**
* 현재 앱 키를 조회합니다 (마스킹된 형태).
*
* @return JsonResponse 마스킹된 앱 키 JSON 응답
*/
public function getAppKey(): JsonResponse
{
try {
$maskedKey = $this->settingsService->maskAppKey();
return $this->success('common.success', [
'app_key' => $maskedKey,
]);
} catch (\Exception $e) {
return $this->error('common.error_occurred', 500, $e->getMessage());
}
}
/**
* 앱 키를 재생성합니다.
*
* @param RegenerateAppKeyRequest $request 앱 키 재생성 요청 데이터
* @return JsonResponse 재생성된 앱 키 JSON 응답
*/
public function regenerateAppKey(RegenerateAppKeyRequest $request): JsonResponse
{
try {
$result = $this->settingsService->regenerateAppKey($request->password);
if (! $result['success']) {
return $this->error($result['error'], 401);
}
return $this->success('settings.app_key_regenerated', [
'app_key' => $result['app_key'],
]);
} catch (\Exception $e) {
return $this->error('settings.app_key_regenerate_failed', 500, $e->getMessage());
}
}
/**
* 설정을 백업합니다.
*
* @return JsonResponse 백업 결과 JSON 응답
*/
public function backup(): JsonResponse
{
try {
$backupPath = $this->settingsService->backupSettings();
return $this->success('settings.backup_success', [
'backup_path' => $backupPath,
]);
} catch (\Exception $e) {
return $this->error('settings.backup_failed', 500, $e->getMessage());
}
}
/**
* 백업에서 설정을 복원합니다.
*
* @param RestoreSettingsRequest $request 복원 요청 데이터 (백업 경로)
* @return JsonResponse 복원 결과 JSON 응답
*/
public function restore(RestoreSettingsRequest $request): JsonResponse
{
try {
$backupPath = $request->validated('backup_path');
$result = $this->settingsService->restoreSettings($backupPath);
if ($result) {
return $this->success('settings.restore_success');
}
return $this->error('settings.restore_failed');
} catch (\Exception $e) {
return $this->error('settings.restore_error', 500, $e->getMessage());
}
}
/**
* 테스트 메일을 발송합니다.
*
* @param TestMailRequest $request 테스트 메일 발송 요청 데이터
* @return JsonResponse 발송 결과 JSON 응답
*/
public function testMail(TestMailRequest $request): JsonResponse
{
try {
$validated = $request->validated();
$toEmail = $validated['to_email'];
$mailSettings = collect($validated)->except('to_email')->filter(fn ($v) => $v !== null)->toArray();
$result = $this->settingsService->sendTestMail($toEmail, $mailSettings);
if ($result['success']) {
return $this->success($result['message'], [
'subject' => $result['subject'],
'body' => $result['body'],
]);
}
return $this->error($result['message'], 500, $result['error'] ?? null);
} catch (\Exception $e) {
return $this->error('settings.test_mail_error', 500, $e->getMessage());
}
}
/**
* 드라이버 연결을 테스트합니다.
*
* S3, Redis, Memcached, Websocket 등 외부 서비스 드라이버의
* 연결 상태를 테스트합니다.
*
* @param TestDriverConnectionRequest $request 드라이버 테스트 요청 데이터
* @return JsonResponse 테스트 결과 JSON 응답
*/
public function testDriverConnection(TestDriverConnectionRequest $request): JsonResponse
{
try {
$settings = $request->validated();
$result = $this->driverConnectionTester->testAll($settings);
if ($result['all_passed']) {
return $this->success('settings.driver_test_success', $result);
}
// 일부 테스트 실패 시에도 결과 반환 (성공 상태지만 all_passed가 false)
return $this->success('settings.driver_test_partial', $result);
} catch (\Exception $e) {
return $this->error('settings.driver_test_error', 500, $e->getMessage());
}
}
}