확장이 훅으로 등록한 카탈로그에서 고른 값은 그 확장이 사라져도 저장값으로 남는다. 그 상태에서 값만 보고 판정하면 이미 제공 불가한 항목이 사용자 화면에 그대로 노출된다. 예외도 경고도 로그도 남지 않아 관리자 화면과 나란히 보지 않으면 드러나지 않는다. - 결제수단에 지정한 PG, 현금영수증 발급사, 검색엔진 드라이버 세 축에 같은 판정을 적용. 공개 응답에서는 제거하고 관리자 응답에는 표시를 남긴다 — 운영자가 고쳐야 할 대상이라 감추면 복구 경로가 사라진다. 서버 검증도 같은 판정을 공유해 화면 우회 제출을 막는다. - 설정 단건 저장이 벌크 저장과 다른 경로를 타던 문제를 코어·이커머스·게시판에서 정리. 저장은 성공하고 파생 값만 갱신되지 않아 어느 화면에서 저장했느냐로 결과가 갈렸다. - 단건 저장의 값 형태 계약 신설. 빈 값으로 되돌릴 수 있게 하고, 타입은 defaults.json 의 기본값을 SSoT 로 삼는다. 해석 불가한 입력은 조용히 캐스팅하지 않고 거절한다. - 드라이버 셀렉트를 서버 카탈로그에 바인딩하고, 저장값이 카탈로그에 없으면 그 값을 드러내는 안내를 8곳 전수에 붙인다. 종전에는 빈 칸으로만 보여 무엇이 저장됐는지 알 수 없었다. 시나리오 매니페스트 8종을 면제 없이 신설했다. `audit:allow` 는 cross product 와 effects 검사를 함께 끄므로, 면제가 걸린 매니페스트에 선언을 추가하면 검증이 회복되지 않는다. 그 아래에서 대응 테스트가 없는 effect 이름 2건이 살아남아 있었고, 함께 정리했다.
523 lines
20 KiB
PHP
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;
|
|
}
|
|
}
|