Files
Gnuboard7/modules/_bundled/sirsoft-ecommerce/src/Http/Controllers/Admin/EcommerceSettingsController.php
T
HeuJung d970117149 fix(core,ecommerce,board,admin_basic): 고아 카탈로그 차단 + 설정 저장 파이프라인 정합
확장이 훅으로 등록한 카탈로그에서 고른 값은 그 확장이 사라져도 저장값으로 남는다.
그 상태에서 값만 보고 판정하면 이미 제공 불가한 항목이 사용자 화면에 그대로 노출된다.
예외도 경고도 로그도 남지 않아 관리자 화면과 나란히 보지 않으면 드러나지 않는다.

- 결제수단에 지정한 PG, 현금영수증 발급사, 검색엔진 드라이버 세 축에 같은 판정을 적용.
 공개 응답에서는 제거하고 관리자 응답에는 표시를 남긴다 — 운영자가 고쳐야 할 대상이라
 감추면 복구 경로가 사라진다. 서버 검증도 같은 판정을 공유해 화면 우회 제출을 막는다.
- 설정 단건 저장이 벌크 저장과 다른 경로를 타던 문제를 코어·이커머스·게시판에서 정리.
 저장은 성공하고 파생 값만 갱신되지 않아 어느 화면에서 저장했느냐로 결과가 갈렸다.
- 단건 저장의 값 형태 계약 신설. 빈 값으로 되돌릴 수 있게 하고, 타입은 defaults.json 의
 기본값을 SSoT 로 삼는다. 해석 불가한 입력은 조용히 캐스팅하지 않고 거절한다.
- 드라이버 셀렉트를 서버 카탈로그에 바인딩하고, 저장값이 카탈로그에 없으면 그 값을
 드러내는 안내를 8곳 전수에 붙인다. 종전에는 빈 칸으로만 보여 무엇이 저장됐는지
 알 수 없었다.

시나리오 매니페스트 8종을 면제 없이 신설했다. `audit:allow` 는 cross product 와 effects
검사를 함께 끄므로, 면제가 걸린 매니페스트에 선언을 추가하면 검증이 회복되지 않는다.
그 아래에서 대응 테스트가 없는 effect 이름 2건이 살아남아 있었고, 함께 정리했다.
2026-08-14 17:28:49 +09:00

523 lines
20 KiB
PHP

<?php
namespace Modules\Sirsoft\Ecommerce\Http\Controllers\Admin;
use App\Extension\HookManager;
use App\Helpers\PermissionHelper;
use App\Helpers\ResponseHelper;
use App\Http\Controllers\Api\Base\AdminBaseController;
use App\Seo\Contracts\SeoCacheManagerInterface;
use App\Services\DriverRegistryService;
use App\Services\NotificationChannelService;
use App\Services\NotificationDefinitionService;
use Exception;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Arr;
use Modules\Sirsoft\Ecommerce\Enums\ShippingApiAuthType;
use Modules\Sirsoft\Ecommerce\Enums\ShippingApiHttpMethod;
use Modules\Sirsoft\Ecommerce\Enums\ShippingApiRequestField;
use Modules\Sirsoft\Ecommerce\Enums\ShippingApiResponseType;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\GetSettingRequest;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\StoreBanksRequest;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\StoreEcommerceSettingsRequest;
use Modules\Sirsoft\Ecommerce\Http\Requests\Admin\UpdateSettingRequest;
use Modules\Sirsoft\Ecommerce\Services\ClaimReasonService;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
use Modules\Sirsoft\Ecommerce\Services\ShippingCarrierService;
use Modules\Sirsoft\Ecommerce\Services\ShippingTypeService;
/**
* 이커머스 모듈 환경설정 컨트롤러
*
* 이커머스 모듈의 환경설정을 관리하는 API를 제공합니다.
*/
class EcommerceSettingsController extends AdminBaseController
{
public function __construct(
private EcommerceSettingsService $settingsService,
private ShippingCarrierService $carrierService,
private ShippingTypeService $shippingTypeService,
private ClaimReasonService $claimReasonService
) {}
/**
* 모든 이커머스 설정을 조회합니다.
*
* @return JsonResponse 설정 목록을 포함한 JSON 응답
*/
public function index(): JsonResponse
{
try {
$settings = $this->settingsService->getAllSettings();
$settings = $this->appendCarriersToSettings($settings);
$settings = $this->appendShippingTypesToSettings($settings);
$settings = $this->appendShippingApiRequestFieldsToSettings($settings);
$settings = $this->appendClaimReasonsToSettings($settings);
$settings = $this->appendMileageNotificationChannelsToSettings($settings);
$settings['available_pg_providers'] = $this->settingsService->getRegisteredPgProviders();
// 현금영수증 발급 프로바이더 후보 — 결제 플러그인이 훅으로 자신을 등록한다.
// 발급 PG 와 결제 PG 는 독립 선택이므로 목록도 별도로 내린다 (KG 결제 + 토스 발급 등).
$settings['available_cash_receipt_providers'] = $this->settingsService->getRegisteredCashReceiptProviders();
// 공개 자산 디스크 선택지 — 카탈로그 SSoT 는 코어 DriverRegistryService 게터 한 곳
$settings['available_public_asset_disks'] = app(DriverRegistryService::class)
->getAvailableDrivers('public_asset');
$settings['abilities'] = [
'can_update' => PermissionHelper::check('sirsoft-ecommerce.settings.update', request()->user()),
];
// 화면의 숫자 입력 경계값 — 저장 규칙과 같은 출처를 내려준다.
// 화면이 리터럴을 들면 규칙이 바뀔 때 따라오지 못해 "화면은 받는데 저장은 422" 가 된다.
$settings['_meta'] = ['limits' => config('sirsoft-ecommerce.limits', [])];
return ResponseHelper::moduleSuccess(
'sirsoft-ecommerce',
'messages.settings.fetch_success',
$settings
);
} catch (Exception $e) {
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'messages.settings.fetch_failed',
500
);
}
}
/**
* 카테고리별 설정을 조회합니다.
*
* @param string $category 카테고리명
* @return JsonResponse 카테고리 설정을 포함한 JSON 응답
*/
public function show(string $category): JsonResponse
{
try {
$settings = $this->settingsService->getSettings($category);
return ResponseHelper::moduleSuccess(
'sirsoft-ecommerce',
'messages.settings.fetch_success',
[
'category' => $category,
'settings' => $settings,
'abilities' => [
'can_update' => PermissionHelper::check('sirsoft-ecommerce.settings.update', request()->user()),
],
]
);
} catch (Exception $e) {
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'messages.settings.fetch_failed',
500
);
}
}
/**
* 이커머스 설정을 저장합니다.
*
* @param StoreEcommerceSettingsRequest $request 저장 요청 데이터
* @return JsonResponse 저장 결과 JSON 응답
*/
public function store(StoreEcommerceSettingsRequest $request): JsonResponse
{
try {
$settings = $request->validatedSettings();
// shipping.carriers는 DB 관리 대상 — JSON 저장에서 제외
$carriers = null;
if (isset($settings['shipping']['carriers'])) {
$carriers = $settings['shipping']['carriers'];
unset($settings['shipping']['carriers']);
}
// shipping.types는 DB 관리 대상 — JSON 저장에서 제외
$shippingTypes = null;
if (isset($settings['shipping']['types'])) {
$shippingTypes = $settings['shipping']['types'];
unset($settings['shipping']['types']);
}
// claim.refund_reasons는 DB 관리 대상 — JSON 저장에서 제외
$refundReasons = null;
if (isset($settings['claim']['refund_reasons'])) {
$refundReasons = $settings['claim']['refund_reasons'];
unset($settings['claim']['refund_reasons']);
}
$result = $this->settingsService->saveSettings($settings);
// carriers DB 동기화
if ($result && $carriers !== null) {
$this->carrierService->syncCarriers($carriers);
}
// shipping types DB 동기화
if ($result && $shippingTypes !== null) {
$this->shippingTypeService->syncShippingTypes($shippingTypes);
}
// claim reasons DB 동기화
if ($result && $refundReasons !== null) {
$this->claimReasonService->syncReasons('refund', $refundReasons);
}
if ($result) {
// 설정 저장 활동로그 (저장된 카테고리 목록 전달)
HookManager::doAction('sirsoft-ecommerce.settings.after_save', array_keys($settings));
// 코어 모듈 설정 저장 훅 — SEO 캐시 무효화 등 코어/타 확장 리스너가 구독한다.
// 발화 지점을 서비스가 아니라 관리자 컨트롤러에 두는 이유: 훅 의미가
// "관리자가 설정을 저장했다" 이고, 서비스에 두면 내부 저장 호출 전부가
// 활동로그·캐시 무효화를 유발한다.
HookManager::doAction('core.module_settings.after_save', 'sirsoft-ecommerce', $settings, $result);
// 저장 후 전체 설정 반환 (관리자 UI 상태 업데이트용)
$updatedSettings = $this->settingsService->getAllSettings();
$updatedSettings = $this->appendCarriersToSettings($updatedSettings);
$updatedSettings = $this->appendShippingTypesToSettings($updatedSettings);
$updatedSettings = $this->appendShippingApiRequestFieldsToSettings($updatedSettings);
$updatedSettings = $this->appendClaimReasonsToSettings($updatedSettings);
$updatedSettings = $this->appendMileageNotificationChannelsToSettings($updatedSettings);
$updatedSettings['available_pg_providers'] = $this->settingsService->getRegisteredPgProviders();
$updatedSettings['available_cash_receipt_providers'] = $this->settingsService->getRegisteredCashReceiptProviders();
// 공개 자산 디스크 선택지 — 저장 응답에도 재부착 (관리자 UI 상태 갱신용)
$updatedSettings['available_public_asset_disks'] = app(DriverRegistryService::class)
->getAvailableDrivers('public_asset');
return ResponseHelper::moduleSuccess(
'sirsoft-ecommerce',
'messages.settings.save_success',
$updatedSettings
);
} else {
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'messages.settings.save_failed',
400
);
}
} catch (Exception $e) {
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'messages.settings.save_error',
500
);
}
}
/**
* 은행 목록만 저장합니다.
*
* @param StoreBanksRequest $request 은행 목록 저장 요청 데이터
* @return JsonResponse 저장 결과 JSON 응답
*/
public function storeBanks(StoreBanksRequest $request): JsonResponse
{
try {
$banks = $request->validated('banks') ?? [];
$result = $this->settingsService->saveBanks($banks);
if ($result) {
HookManager::doAction(
'core.module_settings.after_save',
'sirsoft-ecommerce',
['order_settings' => ['banks' => $banks]],
$result
);
$updatedSettings = $this->settingsService->getAllSettings();
return ResponseHelper::moduleSuccess(
'sirsoft-ecommerce',
'messages.settings.save_success',
$updatedSettings
);
} else {
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'messages.settings.save_failed',
400
);
}
} catch (Exception $e) {
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'messages.settings.save_error',
500
);
}
}
/**
* 특정 설정값을 조회합니다.
*
* @param GetSettingRequest $request 요청 데이터
* @return JsonResponse 설정값을 포함한 JSON 응답
*/
public function getSetting(GetSettingRequest $request): JsonResponse
{
try {
$key = $request->validated('key');
$value = $this->settingsService->getSetting($key);
return ResponseHelper::moduleSuccess(
'sirsoft-ecommerce',
'messages.settings.fetch_success',
[
'key' => $key,
'value' => $value,
]
);
} catch (Exception $e) {
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'messages.settings.fetch_failed',
500
);
}
}
/**
* 특정 설정값을 업데이트합니다.
*
* @param UpdateSettingRequest $request 요청 데이터
* @return JsonResponse 업데이트 결과 JSON 응답
*/
public function updateSetting(UpdateSettingRequest $request): JsonResponse
{
try {
$key = $request->validated('key');
$value = $request->validated('value');
$result = $this->settingsService->setSetting($key, $value);
if ($result) {
// dot-key 를 카테고리 하위 구조로 되돌려 벌크 저장과 같은 payload 형태로 만든다
$payload = [];
Arr::set($payload, $key, $value);
HookManager::doAction('core.module_settings.after_save', 'sirsoft-ecommerce', $payload, $result);
return ResponseHelper::moduleSuccess(
'sirsoft-ecommerce',
'messages.settings.update_success',
['updated' => true]
);
} else {
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'messages.settings.update_failed',
400
);
}
} catch (Exception $e) {
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'messages.settings.update_error',
500
);
}
}
/**
* 설정 캐시를 초기화합니다.
*
* @return JsonResponse 초기화 결과 JSON 응답
*/
public function clearCache(): JsonResponse
{
try {
$this->settingsService->clearCache();
app(SeoCacheManagerInterface::class)->clearAll();
return ResponseHelper::moduleSuccess(
'sirsoft-ecommerce',
'messages.settings.cache_clear_success',
['cleared' => true]
);
} catch (Exception $e) {
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'messages.settings.cache_clear_error',
500
);
}
}
/**
* SEO 캐시 정보를 조회합니다.
*
* @return JsonResponse 캐시 페이지 수 및 용량을 포함한 JSON 응답
*/
public function seoCacheInfo(): JsonResponse
{
try {
$cacheManager = app(SeoCacheManagerInterface::class);
$urls = $cacheManager->getCachedUrls();
$count = count($urls);
// 각 캐시 엔트리의 HTML 크기를 합산하여 총 용량 계산
$totalBytes = 0;
foreach ($urls as $url) {
foreach (config('app.supported_locales', ['ko']) as $locale) {
$html = $cacheManager->get($url, $locale);
if ($html !== null) {
$totalBytes += strlen($html);
}
}
}
return ResponseHelper::moduleSuccess(
'sirsoft-ecommerce',
'messages.settings.fetch_success',
[
'count' => $count,
'size_bytes' => $totalBytes,
'size_formatted' => $this->formatBytes($totalBytes),
]
);
} catch (Exception $e) {
return ResponseHelper::moduleError(
'sirsoft-ecommerce',
'messages.settings.fetch_failed',
500
);
}
}
/**
* 바이트를 사람이 읽기 쉬운 형태로 변환합니다.
*
* @param int $bytes 바이트 수
* @return string 포맷된 문자열 (예: "1.5 MB")
*/
private function formatBytes(int $bytes): string
{
if ($bytes === 0) {
return '0 B';
}
$units = ['B', 'KB', 'MB', 'GB'];
$i = (int) floor(log($bytes, 1024));
$i = min($i, count($units) - 1);
return round($bytes / (1024 ** $i), 1).' '.$units[$i];
}
/**
* 설정 응답에 배송사 목록을 추가합니다.
*
* DB 관리 대상인 carriers를 shipping 섹션에 포함시킵니다.
*
* @param array $settings 설정 배열
* @return array carriers가 추가된 설정 배열
*/
private function appendCarriersToSettings(array $settings): array
{
$carriers = $this->carrierService->getAllCarriers();
$settings['shipping']['carriers'] = $carriers->map(fn ($c) => [
'id' => $c->id,
'code' => $c->code,
'name' => $c->name,
'type' => $c->type,
'tracking_url' => $c->tracking_url,
'is_active' => $c->is_active,
'sort_order' => $c->sort_order,
])->values()->toArray();
return $settings;
}
/**
* 설정 응답에 배송유형 목록을 추가합니다.
*
* DB 관리 대상인 shipping types를 shipping 섹션에 포함시킵니다.
*
* @param array $settings 설정 배열
* @return array shipping types가 추가된 설정 배열
*/
private function appendShippingTypesToSettings(array $settings): array
{
$settings['shipping']['types'] = $this->shippingTypeService->getTypesForSettings();
return $settings;
}
/**
* 설정 응답에 배송 계산 API 요청 참고 필드 후보 목록을 추가합니다.
*
* 배송정책 폼의 "API 전송 필드" 후보(체크박스)는 프론트 하드코딩이 아니라
* 백엔드 SSoT enum(ShippingApiRequestField)에서 번역된 {value, label} 로 내려갑니다.
*
* @param array $settings 설정 배열
* @return array 후보 목록이 추가된 설정 배열
*/
private function appendShippingApiRequestFieldsToSettings(array $settings): array
{
$settings['shipping']['api_request_fields'] = ShippingApiRequestField::options();
// 계산 API 고급 설정 옵션 — 프론트 하드코딩이 아닌 백엔드 enum SSoT 에서 번역되어 내려감
$settings['shipping']['api_http_methods'] = ShippingApiHttpMethod::options();
$settings['shipping']['api_auth_types'] = ShippingApiAuthType::options();
$settings['shipping']['api_response_types'] = ShippingApiResponseType::options();
return $settings;
}
/**
* 설정 응답에 클레임 사유 목록을 추가합니다.
*
* DB 관리 대상인 claim reasons를 claim 섹션에 포함시킵니다.
*
* @param array $settings 설정 배열
* @return array claim reasons가 추가된 설정 배열
*/
private function appendClaimReasonsToSettings(array $settings): array
{
$settings['claim']['refund_reasons'] = $this->claimReasonService->getReasonsForSettings('refund');
return $settings;
}
/**
* 마일리지 소멸 예정 알림의 실제 활성 채널을 설정 응답에 병합합니다.
*
* 설정 카드의 "활성 채널" 칩은 선언 고정값이 아니라 관리자가 알림 설정에서 켜고 끈
* 실제 활성 채널을 보여줍니다. GenericNotification::via() 와 동일하게 도출합니다:
* definition resolve → notification.channels 필터 → 확장 단위 채널 활성 여부.
*
* @param array $settings 설정 배열
* @return array 활성 채널이 병합된 설정 배열
*/
private function appendMileageNotificationChannelsToSettings(array $settings): array
{
$type = 'mileage_expiring_soon';
try {
$definition = app(NotificationDefinitionService::class)->resolve($type);
$channels = $definition?->channels ?? ['mail', 'database'];
$channels = HookManager::applyFilters(
'sirsoft-ecommerce.notification.channels',
$channels,
$type
);
$channelService = app(NotificationChannelService::class);
$active = array_values(array_filter(
$channels,
fn (string $channel) => $channelService->isChannelEnabledForExtension('module', 'sirsoft-ecommerce', $channel)
));
} catch (Exception) {
$active = [];
}
$settings['mileage']['notification_channels'] = $active;
return $settings;
}
}