관리자 환경설정에서 미입금 자동취소 기한을 저장하면 HTML number 입력의 문자열이 그대로 영속되고, Carbon 3.13 의 strict 타입 경계에서 TypeError 가 발생해 무통장입금·가상계좌 주문이 전면 실패했다(공개 제보). 한 곳만 막으면 다른 저장 경로가 남으므로 저장·조회·소비·모델 네 계층에 각각 방어를 두고, 저장소 전역에서 같은 형태가 더 없음을 audit 룰로 확정했다. 같은 계획서 수행 중 드러난 인접 결함도 함께 해소했다. - 쿠폰 수정 경로로 (발급일 기준, 일수 NULL) 조합이 저장되어 발급 즉시 만료된 쿠폰이 조용히 나가던 문제 — 저장·발급 양쪽에서 차단 - 리뷰 작성 기한을 화면과 저장 판정이 서로 다른 기준으로 계산하던 문제 - 결제 플러그인이 PaymentMethodEnum 과 다른 어휘로 지원 결제수단을 선언해 가상계좌·계좌이체·휴대폰결제에 PG사를 지정할 수 없던 문제 엔진에서는 `_localInit` 적용 여부를 전역 해시로만 판정해 인스턴스별 저장소가 리셋되지 않던 결함(engine-v1.54.5)과, 제거 대상이 없어도 상태 갱신·캐시 무효화가 상시 발생하던 판정 결함(engine-v1.54.6)을 고쳤다. Playwright MCP 정밀 점검(T1~T11)에서 설정이 빈 문자열로 남은 사이트의 주문서 입금기한 안내가 미치환 플레이스홀더를 노출하는 것을 실측해, 화면 폴백을 서버와 같은 규칙으로 맞췄다.
302 lines
10 KiB
PHP
302 lines
10 KiB
PHP
<?php
|
|
|
|
namespace App\Traits;
|
|
|
|
/**
|
|
* 환경설정 데이터 정규화 Trait
|
|
*
|
|
* 설정 저장/조회 시 defaults.json 스키마에 맞게 데이터를 정규화합니다.
|
|
* 다국어 필드(배열)가 문자열로 저장된 경우 배열로 변환하는 등의 처리를 수행합니다.
|
|
*
|
|
* @example
|
|
* ```php
|
|
* class EcommerceSettingsService
|
|
* {
|
|
* use NormalizesSettingsData;
|
|
*
|
|
* public function getAllSettings(): array
|
|
* {
|
|
* $settings = $this->loadSettings();
|
|
* return $this->normalizeSettingsData($settings, $defaults);
|
|
* }
|
|
* }
|
|
* ```
|
|
*/
|
|
trait NormalizesSettingsData
|
|
{
|
|
/**
|
|
* 설정 데이터를 defaults 스키마에 맞게 정규화합니다.
|
|
*
|
|
* @param array $settings 정규화할 설정 데이터
|
|
* @param array $defaults defaults.json의 defaults 섹션
|
|
* @return array 정규화된 설정 데이터
|
|
*/
|
|
protected function normalizeSettingsData(array $settings, array $defaults): array
|
|
{
|
|
foreach ($settings as $category => $categorySettings) {
|
|
if (! is_array($categorySettings)) {
|
|
continue;
|
|
}
|
|
|
|
$categoryDefaults = $defaults[$category] ?? [];
|
|
$settings[$category] = $this->normalizeCategoryData($categorySettings, $categoryDefaults);
|
|
}
|
|
|
|
return $settings;
|
|
}
|
|
|
|
/**
|
|
* 카테고리별 설정 데이터를 정규화합니다.
|
|
*
|
|
* @param array $settings 카테고리 설정 데이터
|
|
* @param array $defaults 카테고리 기본값
|
|
* @return array 정규화된 카테고리 설정
|
|
*/
|
|
protected function normalizeCategoryData(array $settings, array $defaults): array
|
|
{
|
|
foreach ($settings as $key => $value) {
|
|
$defaultValue = $defaults[$key] ?? null;
|
|
|
|
// 기본값이 배열인데 현재 값이 문자열인 경우 (다국어 필드)
|
|
if (is_array($defaultValue) && is_string($value)) {
|
|
$settings[$key] = $this->convertStringToMultilingual($value, $defaultValue);
|
|
}
|
|
|
|
// 기본값이 숫자인데 현재 값이 숫자 문자열인 경우 (HTML number 입력 등)
|
|
$numeric = $this->normalizeNumericScalar($value, $defaultValue);
|
|
if ($numeric !== null) {
|
|
$settings[$key] = $numeric;
|
|
}
|
|
|
|
// 배열 내부의 객체도 정규화 (currencies 같은 경우)
|
|
if (is_array($value) && is_array($defaultValue)) {
|
|
$settings[$key] = $this->isIndexedArray($value)
|
|
? $this->normalizeArrayItems($value, $defaultValue)
|
|
: $this->normalizeNestedGroup($value, $defaultValue);
|
|
}
|
|
}
|
|
|
|
return $settings;
|
|
}
|
|
|
|
/**
|
|
* 중첩된 연관 배열(설정 그룹)을 같은 규칙으로 재귀 정규화합니다.
|
|
*
|
|
* 인덱스 배열은 "같은 구조의 항목 목록" 이라 normalizeArrayItems 가 담당하지만,
|
|
* 연관 배열은 "하위 설정 그룹" 이므로 카테고리와 동일하게 다루어야 합니다.
|
|
* 이 분기가 없으면 그룹 한 단계 아래의 숫자 문자열이 정규화되지 않고 남습니다.
|
|
*
|
|
* 다국어 필드(`{"ko": "...", "en": "..."}`)도 연관 배열이지만, 기본값의 각 로케일 값이
|
|
* 문자열이라 숫자 캐스트 조건에 걸리지 않아 그대로 통과합니다.
|
|
*
|
|
* @param array $group 중첩 설정 그룹
|
|
* @param array $defaults 대응 기본값 그룹
|
|
* @return array 정규화된 그룹
|
|
*/
|
|
protected function normalizeNestedGroup(array $group, array $defaults): array
|
|
{
|
|
return $this->normalizeCategoryData($group, $defaults);
|
|
}
|
|
|
|
/**
|
|
* 배열 내부 항목들을 정규화합니다.
|
|
*
|
|
* currencies 배열처럼 객체 배열인 경우 각 항목을 정규화합니다.
|
|
*
|
|
* @param array $items 항목 배열
|
|
* @param array $defaultItems 기본 항목 배열
|
|
* @return array 정규화된 항목 배열
|
|
*/
|
|
protected function normalizeArrayItems(array $items, array $defaultItems): array
|
|
{
|
|
// 기본값 배열에서 첫 번째 항목의 구조를 참조
|
|
$templateItem = $defaultItems[0] ?? null;
|
|
|
|
if (! is_array($templateItem)) {
|
|
return $items;
|
|
}
|
|
|
|
// 기본값 항목들을 식별자(code 등)로 인덱싱
|
|
$defaultItemsIndexed = $this->indexItemsByIdentifier($defaultItems);
|
|
|
|
foreach ($items as $index => $item) {
|
|
if (! is_array($item)) {
|
|
continue;
|
|
}
|
|
|
|
// 해당 항목의 기본값 찾기 (code로 매칭)
|
|
$itemIdentifier = $item['code'] ?? $item['id'] ?? null;
|
|
$matchingDefault = $itemIdentifier !== null
|
|
? ($defaultItemsIndexed[$itemIdentifier] ?? $templateItem)
|
|
: $templateItem;
|
|
|
|
$items[$index] = $this->normalizeItem($item, $matchingDefault);
|
|
}
|
|
|
|
return $items;
|
|
}
|
|
|
|
/**
|
|
* 단일 항목을 정규화합니다.
|
|
*
|
|
* @param array $item 정규화할 항목
|
|
* @param array $defaultItem 기본값 항목
|
|
* @return array 정규화된 항목
|
|
*/
|
|
protected function normalizeItem(array $item, array $defaultItem): array
|
|
{
|
|
foreach ($item as $key => $value) {
|
|
$defaultValue = $defaultItem[$key] ?? null;
|
|
|
|
// 기본값이 배열인데 현재 값이 문자열인 경우 (다국어 필드)
|
|
if (is_array($defaultValue) && is_string($value)) {
|
|
$item[$key] = $this->convertStringToMultilingual($value, $defaultValue);
|
|
}
|
|
|
|
// 기본값이 숫자인데 현재 값이 숫자 문자열인 경우 (환율/소수 자릿수 등)
|
|
$numeric = $this->normalizeNumericScalar($value, $defaultValue);
|
|
if ($numeric !== null) {
|
|
$item[$key] = $numeric;
|
|
}
|
|
|
|
// 항목 내부의 중첩 그룹도 동일 규칙으로 정규화
|
|
if (is_array($value) && is_array($defaultValue) && ! $this->isIndexedArray($value)) {
|
|
$item[$key] = $this->normalizeNestedGroup($value, $defaultValue);
|
|
}
|
|
}
|
|
|
|
return $item;
|
|
}
|
|
|
|
/**
|
|
* 숫자 기본값을 가진 설정의 숫자 문자열을 스칼라 숫자로 정규화합니다.
|
|
*
|
|
* HTML number 입력의 DOM 값은 문자열이고 검증 규칙(integer/numeric)은 숫자 문자열을
|
|
* 통과시키되 캐스트하지 않으므로, 설정 파일에 문자열로 영속될 수 있습니다.
|
|
* 그 값이 Carbon 처럼 strict 타입을 요구하는 경계에 닿으면 TypeError 가 발생하므로
|
|
* 조회 시점에 defaults 스키마의 타입으로 되돌립니다.
|
|
*
|
|
* 기본값이 int/float 스칼라이고 값이 숫자 문자열일 때만 변환하며,
|
|
* null·불리언·배열·비숫자 문자열은 변경하지 않습니다.
|
|
*
|
|
* @param mixed $value 현재 설정값
|
|
* @param mixed $defaultValue defaults 스키마의 기본값
|
|
* @return int|float|null 정규화된 값 (대상이 아니면 null)
|
|
*/
|
|
protected function normalizeNumericScalar(mixed $value, mixed $defaultValue): int|float|null
|
|
{
|
|
if (! is_string($value) || ! is_numeric(trim($value))) {
|
|
return null;
|
|
}
|
|
|
|
// is_int/is_float 는 불리언을 포함하지 않으므로 bool 기본값은 자동 제외된다.
|
|
if (is_int($defaultValue)) {
|
|
return (int) trim($value);
|
|
}
|
|
|
|
if (is_float($defaultValue)) {
|
|
return (float) trim($value);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 문자열을 다국어 배열로 변환합니다.
|
|
*
|
|
* 기본값의 구조를 참조하여 다국어 배열을 생성합니다.
|
|
* 기본값에 해당 로케일의 값이 있으면 사용하고, 없으면 입력 문자열을 사용합니다.
|
|
*
|
|
* @param string $value 변환할 문자열
|
|
* @param array $defaultValue 기본값 (다국어 배열)
|
|
* @return array 다국어 배열
|
|
*/
|
|
protected function convertStringToMultilingual(string $value, array $defaultValue): array
|
|
{
|
|
// 기본값이 연관 배열(다국어 형식)인지 확인
|
|
if (! $this->isMultilingualArray($defaultValue)) {
|
|
return [$value]; // 일반 배열이면 그냥 배열로 감싸서 반환
|
|
}
|
|
|
|
$result = [];
|
|
$locales = array_keys($defaultValue);
|
|
|
|
foreach ($locales as $locale) {
|
|
// 기본값에서 해당 로케일의 값이 입력 문자열과 동일하면 기본값 사용
|
|
// 아니면 입력 문자열을 모든 로케일에 할당
|
|
$result[$locale] = $value;
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* 항목 배열을 식별자로 인덱싱합니다.
|
|
*
|
|
* @param array $items 항목 배열
|
|
* @return array 식별자를 키로 하는 연관 배열
|
|
*/
|
|
protected function indexItemsByIdentifier(array $items): array
|
|
{
|
|
$indexed = [];
|
|
|
|
foreach ($items as $item) {
|
|
if (! is_array($item)) {
|
|
continue;
|
|
}
|
|
|
|
$identifier = $item['code'] ?? $item['id'] ?? null;
|
|
if ($identifier !== null) {
|
|
$indexed[$identifier] = $item;
|
|
}
|
|
}
|
|
|
|
return $indexed;
|
|
}
|
|
|
|
/**
|
|
* 인덱스 배열인지 확인합니다.
|
|
*
|
|
* @param array $array 확인할 배열
|
|
* @return bool 인덱스 배열이면 true
|
|
*/
|
|
protected function isIndexedArray(array $array): bool
|
|
{
|
|
if (empty($array)) {
|
|
return false;
|
|
}
|
|
|
|
return array_keys($array) === range(0, count($array) - 1);
|
|
}
|
|
|
|
/**
|
|
* 다국어 배열인지 확인합니다.
|
|
*
|
|
* 로케일 코드(ko, en 등)를 키로 하는 연관 배열인지 확인합니다.
|
|
*
|
|
* @param array $array 확인할 배열
|
|
* @return bool 다국어 배열이면 true
|
|
*/
|
|
protected function isMultilingualArray(array $array): bool
|
|
{
|
|
if (empty($array)) {
|
|
return false;
|
|
}
|
|
|
|
// 인덱스 배열이면 다국어 배열이 아님
|
|
if ($this->isIndexedArray($array)) {
|
|
return false;
|
|
}
|
|
|
|
// 키가 로케일 코드 형식인지 확인 (2-3자 알파벳)
|
|
$keys = array_keys($array);
|
|
foreach ($keys as $key) {
|
|
if (! is_string($key) || ! preg_match('/^[a-z]{2,3}(-[A-Z]{2})?$/', $key)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
}
|