diff --git a/app/Entities/Customer/ClientEntity.php b/app/Entities/Customer/ClientEntity.php index 16f2162..7d8d1d9 100644 --- a/app/Entities/Customer/ClientEntity.php +++ b/app/Entities/Customer/ClientEntity.php @@ -15,8 +15,8 @@ class ClientEntity extends CustomerEntity 'email' => '', 'role' => [], 'account_balance' => 0, - 'coupon_balance' => 0, - 'point_balance' => 0, + 'coupon_balance' => 0, + 'point_balance' => 0, 'status' => '', 'history' => '' ]; @@ -79,8 +79,8 @@ class ClientEntity extends CustomerEntity } // 2-b. JSON이 아니면 CSV로 가정하고 변환 $parts = explode(DEFAULTS["DELIMITER_ROLE"], $role); - // 각 요소의 불필요한 공백과 따옴표 제거 - $cleanedRoles = array_map(fn($item) => trim($item, " \t\n\r\0\x0B\""), $parts); + // 각 요소의 불필요한 공백과 따옴표 제거. null 가능성에 대비해 string 형변환 추가 + $cleanedRoles = array_map(fn($item) => trim((string) ($item ?? ''), " \t\n\r\0\x0B\""), $parts); return array_filter($cleanedRoles); } // 3. 변환에 실패했거나 데이터가 없는 경우 빈 배열 반환 @@ -109,8 +109,8 @@ class ClientEntity extends CustomerEntity elseif (is_array($role)) { $roleArray = $role; } - // 배열의 각 요소를 정리 - $cleanedRoles = array_map(fn($item) => trim($item, " \t\n\r\0\x0B\""), $roleArray); + // 배열의 각 요소를 정리. null이나 scalar 타입이 섞여있을 경우에 대비해 string으로 명시적 형변환 후 trim 수행 + $cleanedRoles = array_map(fn($item) => trim((string) ($item ?? ''), " \t\n\r\0\x0B\""), $roleArray); $roleArray = array_filter($cleanedRoles); // 최종적으로 DB에 삽입될 단일 CSV 문자열로 변환하여 저장합니다. $this->attributes['role'] = implode(DEFAULTS["DELIMITER_ROLE"], $roleArray); diff --git a/app/Entities/UserEntity.php b/app/Entities/UserEntity.php index fbdf38d..c3969a8 100644 --- a/app/Entities/UserEntity.php +++ b/app/Entities/UserEntity.php @@ -57,8 +57,8 @@ class UserEntity extends CommonEntity } // 2-b. JSON이 아니면 CSV로 가정하고 변환 $parts = explode(DEFAULTS["DELIMITER_ROLE"], $role); - // 각 요소의 불필요한 공백과 따옴표 제거 - $cleanedRoles = array_map(fn($item) => trim($item, " \t\n\r\0\x0B\""), $parts); + // 각 요소의 불필요한 공백과 따옴표 제거. null 가능성에 대비해 string 형변환 추가 + $cleanedRoles = array_map(fn($item) => trim((string) ($item ?? ''), " \t\n\r\0\x0B\""), $parts); return array_filter($cleanedRoles); } // 3. 변환에 실패했거나 데이터가 없는 경우 빈 배열 반환 @@ -95,8 +95,8 @@ class UserEntity extends CommonEntity elseif (is_array($role)) { $roleArray = $role; } - // 배열의 각 요소를 정리 - $cleanedRoles = array_map(fn($item) => trim($item, " \t\n\r\0\x0B\""), $roleArray); + // 배열의 각 요소를 정리. null이나 scalar 타입이 섞여있을 경우에 대비해 string으로 명시적 형변환 후 trim 수행 + $cleanedRoles = array_map(fn($item) => trim((string) ($item ?? ''), " \t\n\r\0\x0B\""), $roleArray); $roleArray = array_filter($cleanedRoles); // 최종적으로 DB에 삽입될 단일 CSV 문자열로 변환하여 저장합니다. $this->attributes['role'] = implode(DEFAULTS["DELIMITER_ROLE"], $roleArray); diff --git a/app/Forms/CommonForm.php b/app/Forms/CommonForm.php index ed0f77b..f8c1ff0 100644 --- a/app/Forms/CommonForm.php +++ b/app/Forms/CommonForm.php @@ -132,7 +132,7 @@ abstract class CommonForm * @param array $formDatas 검증할 데이터 * @throws RuntimeException */ - final public function validate(array $formDatas): void + final public function validate(array &$formDatas): void { if ($this->_validation === null) { throw new RuntimeException(static::class . '->' . __FUNCTION__ . "에서 오류발생: Validation 서비스가 초기화되지 않았습니다."); @@ -141,12 +141,23 @@ abstract class CommonForm try { $dynamicRules = []; - // 0. Ensure all scalar inputs are strings to prevent trim() error on PHP 8.1+ - foreach ($formDatas as $key => $value) { - if (is_scalar($value) && !is_string($value)) { - $formDatas[$key] = (string) $value; + // 0. Ensure all scalar/null inputs are strings to prevent trim() error on PHP 8.1+ + $castToString = function (&$data, $path = '') use (&$castToString) { + foreach ($data as $key => &$value) { + $currentField = $path ? "{$path}.{$key}" : $key; + try { + if (is_array($value)) { + $castToString($value, $currentField); + } elseif ($value === null || (is_scalar($value) && !is_string($value))) { + $data[$key] = (string) ($value ?? ''); + } + } catch (\Throwable $e) { + throw new RuntimeException("데이터 정제 중 오류 발생 (필드: {$currentField}): " . $e->getMessage()); + } } - } + }; + $castToString($formDatas); + // 1. 현재 서비스의 필드 라벨 정보 로드 (언어 파일 기반) $formFields = $this->getFormFields(); $formRules = $this->getFormRules(); @@ -156,48 +167,94 @@ abstract class CommonForm } foreach ($formRules as $field => $rule) { - // 2. 필드명과 규칙 추출 - list($field, $rule) = $this->getValidationRule($field, $rule); + try { + // 2. 필드명과 규칙 추출 + list($field, $rule) = $this->getValidationRule($field, $rule); - // 3. 라벨 결정 로직 (한글 라벨 매핑) - if (isset($formFields[$field])) { - $label = $formFields[$field]; - } elseif (str_contains($field, '.*')) { - // 배열 검증(role.* 등)의 경우 부모 필드의 라벨을 활용 - $parentField = str_replace('.*', '', $field); - $label = ($formFields[$parentField] ?? $field) . " 항목"; - } else { - $label = $field; // 언어 파일에 정의가 없는 경우 필드명 유지 + // 3. 라벨 결정 로직 (한글 라벨 매핑) + if (isset($formFields[$field])) { + $label = $formFields[$field]; + } elseif (str_contains($field, '.*')) { + // 배열 검증(role.* 등)의 경우 부모 필드의 라벨을 활용 + $parentField = str_replace('.*', '', $field); + $label = ($formFields[$parentField] ?? $field) . " 항목"; + } else { + $label = $field; // 언어 파일에 정의가 없는 경우 필드명 유지 + } + + // 4. [핵심 해결책] 규칙 배열 자체에 label을 포함시킴 + // 이렇게 하면 CI4 엔진이 {field} 자리에 이 label 값을 최우선으로 사용합니다. + $dynamicRules[$field] = [ + 'label' => $label, + 'rules' => $rule + ]; + + // 4.5. Ensure the field exists in formDatas to prevent trim(null) in the engine + if (!array_key_exists($field, $formDatas) && !str_contains($field, '.*')) { + $formDatas[$field] = ''; + } + } catch (\Throwable $e) { + throw new RuntimeException("유효성 검사 규칙 준비 중 오류 발생 (필드: {$field}): " . $e->getMessage()); } - - // 4. [핵심 해결책] 규칙 배열 자체에 label을 포함시킴 - // 이렇게 하면 CI4 엔진이 {field} 자리에 이 label 값을 최우선으로 사용합니다. - $dynamicRules[$field] = [ - 'label' => $label, - 'rules' => $rule - ]; } // 5. 검증 규칙 설정 (인자를 하나만 전달하여 설정 충돌 방지) $this->_validation->setRules($dynamicRules); // 6. 검증 실행 - if (!$this->_validation->run($formDatas)) { - // 한글 라벨이 적용된 에러 메시지들을 배열로 가져와 한 줄씩 합침 - $errors = $this->_validation->getErrors(); - throw new RuntimeException(implode("\n", $errors)); + try { + if (!$this->_validation->run($formDatas)) { + // 한글 라벨이 적용된 에러 메시지들을 배열로 가져와 한 줄씩 합침 + $errors = $this->_validation->getErrors(); + throw new RuntimeException(implode("\n", $errors)); + } + } catch (\TypeError $e) { + // TypeError(예: trim(null) 등) 발생 시 범인(field) 찾기 시도 + $culpritField = "알 수 없음"; + $culpritValue = "null"; + + foreach ($dynamicRules as $f => $r) { + if (str_contains($r['rules'], 'trim')) { + // 중첩 필드(.*)와 일반 필드 구분하여 null 체크 + if (str_contains($f, '.*')) { + $parentKey = str_replace('.*', '', $f); + if (isset($formDatas[$parentKey]) && is_array($formDatas[$parentKey])) { + foreach ($formDatas[$parentKey] as $k => $v) { + if ($v === null) { + $culpritField = "{$parentKey}.{$k} ({$r['label']})"; + break 2; + } + } + } + } else { + if (!isset($formDatas[$f]) || $formDatas[$f] === null) { + $culpritField = "{$f} ({$r['label']})"; + break; + } + } + } + } + + $errorMsg = $e->getMessage(); + // "trim(): Argument #1 ($string) must be of type string, null given" 문구에서 Argument #1을 필드명으로 교체 + $errorMsg = str_replace("Argument #1 (\$string)", "데이터(필드: {$culpritField}, 값: {$culpritValue})", $errorMsg); + throw new RuntimeException("검증 도중 타입 오류 발생: " . $errorMsg); } // 검증 성공 시 추가 로직 없이 종료 } catch (\Throwable $e) { + // 이미 RuntimeException으로 포장된 경우 그대로 던짐 + if ($e instanceof RuntimeException) { + throw $e; + } // 오류 발생 시 디버깅을 위해 로그 기록 log_message('debug', '--- Validation Error Detail ---'); log_message('debug', 'Rules: ' . var_export($this->getFormRules(), true)); log_message('debug', 'Data: ' . var_export($formDatas, true)); log_message('debug', 'Message: ' . $e->getMessage()); - throw new RuntimeException($e->getMessage()); + throw new RuntimeException("유효성 검사 중 시스템 오류 발생: " . $e->getMessage()); } } diff --git a/app/Helpers/Customer/ClientHelper.php b/app/Helpers/Customer/ClientHelper.php index 419586b..250b818 100644 --- a/app/Helpers/Customer/ClientHelper.php +++ b/app/Helpers/Customer/ClientHelper.php @@ -22,7 +22,7 @@ class ClientHelper extends CustomerHelper break; case 'role': $currentRoles = is_array($value) - ? array_map('strtolower', array_map('trim', $value)) + ? array_map('strtolower', array_map(fn($item) => trim((string) ($item ?? ''), " \t\n\r\0\x0B\""), $value)) : []; $form = ''; //Form페이지에서는 맨앞에것 제외하기 위함 @@ -60,7 +60,7 @@ class ClientHelper extends CustomerHelper "data-src" => "/admin/customer/wallet/account?clientinfo_uid={$viewDatas['entity']->getPK()}&ActionTemplate=popup", "data-bs-toggle" => "modal", "data-bs-target" => "#modal_action_form", - "class" => "text-primary", + "class" => "text-primary", ...$extras, ] ); @@ -73,7 +73,7 @@ class ClientHelper extends CustomerHelper "data-src" => "/admin/customer/wallet/coupon?clientinfo_uid={$viewDatas['entity']->getPK()}&ActionTemplate=popup", "data-bs-toggle" => "modal", "data-bs-target" => "#modal_action_form", - "class" => "text-primary", + "class" => "text-primary", ...$extras, ] ); @@ -86,7 +86,7 @@ class ClientHelper extends CustomerHelper "data-src" => "/admin/customer/wallet/point?clientinfo_uid={$viewDatas['entity']->getPK()}&ActionTemplate=popup", "data-bs-toggle" => "modal", "data-bs-target" => "#modal_action_form", - "class" => "text-primary", + "class" => "text-primary", ...$extras, ] ); @@ -107,7 +107,7 @@ class ClientHelper extends CustomerHelper case 'batchjob': case 'batchjob_delete': //역활이 보안관리자가 아니면 사용불가 - $action = $this->getAuthContext()->isAccessRole([ROLE['USER']['SECURITY']]) ? parent::getListButton($action, $label, $viewDatas, $extras) : ""; + $action = $this->getAuthContext()->isAccessRole([ROLE['USER']['SECURITY']]) ? parent::getListButton($action, $label, $viewDatas, $extras) : ""; break; case 'modify': //역활이 보안관리자가 아니면 수정불가 @@ -133,7 +133,7 @@ class ClientHelper extends CustomerHelper $label, $action, [ - "data-src" => "/admin/customer/wallet/{$action}?clientinfo_uid={$viewDatas['entity']->getPK()}&ActionTemplate=popup", + "data-src" => "/admin/customer/wallet/{$action}?clientinfo_uid={$viewDatas['entity']->getPK()}&ActionTemplate=popup", "data-bs-toggle" => "modal", "data-bs-target" => "#modal_action_form", "class" => "text-primary", @@ -169,10 +169,10 @@ class ClientHelper extends CustomerHelper $action = "{$label} 0원"; if (array_key_exists($viewDatas['entity']->getPK(), $viewDatas['unPaids'])) { $action = form_label( - sprintf("%s건/%s원", $viewDatas['unPaids'][$viewDatas['entity']->getPK()]['cnt'], number_format($viewDatas['unPaids'][$viewDatas['entity']->getPK()]['amount'])), + sprintf("%s건/%s원", $viewDatas['unPaids'][$viewDatas['entity']->getPK()]['cnt'], number_format($viewDatas['unPaids'][$viewDatas['entity']->getPK()]['amount'])), 'payment_unpaid', [ - "data-src" => "/admin/payment?clientinfo_uid={$viewDatas['entity']->getPK()}&status=unpaid&ActionTemplate=popup", + "data-src" => "/admin/payment?clientinfo_uid={$viewDatas['entity']->getPK()}&status=unpaid&ActionTemplate=popup", "data-bs-toggle" => "modal", "data-bs-target" => "#modal_action_form", "class" => "text-primary form-label-sm",