From 40b809949ccbf44888e2422aef3bf5854f3c90a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EC=A4=80=ED=9D=A0?= Date: Mon, 9 Feb 2026 11:15:16 +0900 Subject: [PATCH] dbmsv4 init...5 --- app/Config/Validation.php | 3 +- app/Entities/Customer/ClientEntity.php | 111 ++++++---- app/Entities/UserEntity.php | 134 ++++++----- app/Forms/CommonForm.php | 295 +++++++++++++++++++------ app/Forms/Customer/ClientForm.php | 12 +- app/Forms/UserForm.php | 14 +- app/Language/ko/Validation.php | 60 +++-- app/Services/CommonService.php | 21 +- app/Validation/CustomRules.php | 32 +++ 9 files changed, 465 insertions(+), 217 deletions(-) create mode 100644 app/Validation/CustomRules.php diff --git a/app/Config/Validation.php b/app/Config/Validation.php index 6342dbb..3c3cdc2 100644 --- a/app/Config/Validation.php +++ b/app/Config/Validation.php @@ -25,6 +25,7 @@ class Validation extends BaseConfig FormatRules::class, FileRules::class, CreditCardRules::class, + \App\Validation\CustomRules::class, // ✅ 추가 ]; /** @@ -34,7 +35,7 @@ class Validation extends BaseConfig * @var array */ public array $templates = [ - 'list' => 'CodeIgniter\Validation\Views\list', + 'list' => 'CodeIgniter\Validation\Views\list', 'single' => 'CodeIgniter\Validation\Views\single', ]; diff --git a/app/Entities/Customer/ClientEntity.php b/app/Entities/Customer/ClientEntity.php index 54cfb03..f390579 100644 --- a/app/Entities/Customer/ClientEntity.php +++ b/app/Entities/Customer/ClientEntity.php @@ -8,10 +8,13 @@ class ClientEntity extends CustomerEntity { const PK = ClientModel::PK; const TITLE = ClientModel::TITLE; + protected array $nullableFields = [ 'id', 'passwd', ]; + + // ✅ role은 반드시 string 기본값 protected $attributes = [ 'id' => null, 'passwd' => null, @@ -19,13 +22,14 @@ class ClientEntity extends CustomerEntity 'name' => '', 'phone' => '', 'email' => '', - 'role' => [], + 'role' => '', // ✅ [] 금지 'account_balance' => 0, 'coupon_balance' => 0, 'point_balance' => 0, 'status' => '', - 'history' => '' + 'history' => '', ]; + public function __construct(array|null $data = null) { parent::__construct($data); @@ -35,91 +39,104 @@ class ClientEntity extends CustomerEntity { return $this->user_uid ?? null; } - //기본기능 + public function getCustomTitle(mixed $title = null): string { return sprintf("%s/%s", $this->getSite(), $title ? $title : $this->getTitle()); } + public function getName(): string { - return $this->name; + return (string) ($this->attributes['name'] ?? ''); } + public function getSite(): string { - return $this->site; + return (string) ($this->attributes['site'] ?? ''); } + public function getAccountBalance(): int { - return $this->account_balance ?? 0; + return (int) ($this->attributes['account_balance'] ?? 0); } + public function getCouponBalance(): int { - return $this->coupon_balance ?? 0; + return (int) ($this->attributes['coupon_balance'] ?? 0); } + public function getPointBalance(): int { - return $this->point_balance ?? 0; + return (int) ($this->attributes['point_balance'] ?? 0); } + public function getHistory(): string|null { - return $this->history; + return $this->attributes['history'] ?? null; } - /* - * 사용자의 역할을 배열 형태로 반환합니다. - * DB의 JSON 또는 CSV 형식 데이터를 모두 배열로 복구할 수 있는 로직을 포함합니다. - * @return array + + /** + * role을 배열로 반환 */ public function getRole(): array { - $role = $this->role ?? null; - // 1. 이미 배열인 경우 (방어적 코딩) + $role = $this->attributes['role'] ?? null; + if (is_array($role)) { - return array_filter($role); + return array_values(array_filter($role, fn($v) => (string) $v !== '')); } - // 2. 문자열 데이터인 경우 처리 - if (is_string($role) && !empty($role)) { - // 2-a. JSON 디코딩 시도 (기존 DB의 JSON 형식 처리) - $decodedRole = json_decode($role, true); - if (json_last_error() === JSON_ERROR_NONE && is_array($decodedRole)) { - return $decodedRole; + + if (is_string($role) && $role !== '') { + $decoded = json_decode($role, true); + if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { + $clean = array_map( + fn($item) => trim((string) ($item ?? ''), " \t\n\r\0\x0B\""), + $decoded + ); + return array_values(array_filter($clean, fn($v) => $v !== '')); } - // 2-b. JSON이 아니면 CSV로 가정하고 변환 + $parts = explode(DEFAULTS["DELIMITER_COMMA"], $role); - // 각 요소의 불필요한 공백과 따옴표 제거. null 가능성에 대비해 string 형변환 추가 - $cleanedRoles = array_map(fn($item) => trim((string) ($item ?? ''), " \t\n\r\0\x0B\""), $parts); - return array_filter($cleanedRoles); + $clean = array_map( + fn($item) => trim((string) ($item ?? ''), " \t\n\r\0\x0B\""), + $parts + ); + return array_values(array_filter($clean, fn($v) => $v !== '')); } - // 3. 변환에 실패했거나 데이터가 없는 경우 빈 배열 반환 + return []; } - // --- Setter Methods --- /** - * Role 데이터가 Entity에 설정될 때 호출되어, 입력된 CSV/JSON 문자열을 정리 후 - * DB에 적합한 CSV 문자열로 최종 저장합니다. - * @param mixed $role 입력 데이터 (문자열 또는 배열) + * ✅ role은 DB 저장용 CSV 문자열로 반환 */ - public function setRole(mixed $role) + public function setRole($role): string { $roleArray = []; - // 입력된 데이터가 문자열인 경우에만 trim 및 explode 처리 + if (is_string($role)) { - // trim()은 여기서 안전하게 호출됩니다. - $cleanRoleString = trim($role, " \t\n\r\0\x0B\""); - if (!empty($cleanRoleString)) { - // 문자열을 구분자로 분리하여 배열로 만듭니다. - $roleArray = explode(DEFAULTS["DELIMITER_COMMA"], $cleanRoleString); + $clean = trim($role, " \t\n\r\0\x0B\""); + if ($clean !== '') { + $decoded = json_decode($clean, true); + if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { + $roleArray = $decoded; + } else { + $roleArray = explode(DEFAULTS["DELIMITER_COMMA"], $clean); + } } - } - // 입력된 데이터가 이미 배열인 경우 (modify_process에서 $formDatas가 배열로 넘어옴) - elseif (is_array($role)) { + } elseif (is_array($role)) { $roleArray = $role; + } else { + $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 문자열로 변환하여 저장합니다. - // ✅ setter함수는 반드시 attributes에 저장 - $this->attributes['role'] = implode(DEFAULTS["DELIMITER_COMMA"], $roleArray); + + $cleaned = array_map( + fn($item) => trim((string) ($item ?? ''), " \t\n\r\0\x0B\""), + $roleArray + ); + + $roleArray = array_values(array_filter($cleaned, fn($v) => $v !== '')); + + return implode(DEFAULTS["DELIMITER_COMMA"], $roleArray); } } diff --git a/app/Entities/UserEntity.php b/app/Entities/UserEntity.php index e5b8862..8f936b8 100644 --- a/app/Entities/UserEntity.php +++ b/app/Entities/UserEntity.php @@ -9,95 +9,119 @@ class UserEntity extends CommonEntity { const PK = Model::PK; const TITLE = Model::TITLE; + protected array $nullableFields = [ 'mobile', + // uid 같은 숫자 PK가 nullable이면 여기에 추가 ]; + + // ✅ role은 반드시 "문자열" 기본값 (DB 저장형) protected $attributes = [ 'id' => '', 'passwd' => '', - 'name' => "", - 'email' => "", + 'name' => '', + 'email' => '', 'mobile' => null, - 'role' => "", + 'role' => '', // ✅ array 금지 'status' => '', ]; + public function __construct(array|null $data = null) { parent::__construct($data); } + public function getID(): string { - return (string) $this->id; + return (string) ($this->attributes['id'] ?? ''); } + public function getPassword(): string { - return $this->passwd; + return (string) ($this->attributes['passwd'] ?? ''); } + /** - * 사용자의 역할을 배열 형태로 반환합니다. - * DB의 JSON 또는 CSV 형식 데이터를 모두 배열로 복구할 수 있는 로직을 포함합니다. - * @return array + * role을 "배열"로 반환 (DB에는 CSV/JSON/배열 무엇이든 복구) */ public function getRole(): array { - $role = $this->role ?? null; - // 1. 이미 배열인 경우 (방어적 코딩) - if (is_array($role)) { - return array_filter($role); - } - // 2. 문자열 데이터인 경우 처리 - if (is_string($role) && !empty($role)) { - // 2-a. JSON 디코딩 시도 (기존 DB의 JSON 형식 처리) - $decodedRole = json_decode($role, true); - if (json_last_error() === JSON_ERROR_NONE && is_array($decodedRole)) { - return $decodedRole; - } - // 2-b. JSON이 아니면 CSV로 가정하고 변환 - $parts = explode(DEFAULTS["DELIMITER_COMMA"], $role); - // 각 요소의 불필요한 공백과 따옴표 제거. null 가능성에 대비해 string 형변환 추가 - $cleanedRoles = array_map(fn($item) => trim((string) ($item ?? ''), " \t\n\r\0\x0B\""), $parts); - return array_filter($cleanedRoles); - } - // 3. 변환에 실패했거나 데이터가 없는 경우 빈 배열 반환 - return []; - } - // --- Setter Methods --- + $role = $this->attributes['role'] ?? null; - public function setPasswd(string|null $password = null) - { - // 입력된 비밀번호가 null이 아니고 비어있지 않을 때만 해시 처리 - if (!empty($password)) { - // ✅ setter함수는 반드시 attributes에 저장 - $this->attributes['passwd'] = password_hash($password, PASSWORD_BCRYPT); + if (is_array($role)) { + return array_values(array_filter($role, fn($v) => (string) $v !== '')); } + + if (is_string($role) && $role !== '') { + // JSON 시도 + $decoded = json_decode($role, true); + if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { + $clean = array_map( + fn($item) => trim((string) ($item ?? ''), " \t\n\r\0\x0B\""), + $decoded + ); + return array_values(array_filter($clean, fn($v) => $v !== '')); + } + + // CSV fallback + $parts = explode(DEFAULTS["DELIMITER_COMMA"], $role); + $clean = array_map( + fn($item) => trim((string) ($item ?? ''), " \t\n\r\0\x0B\""), + $parts + ); + return array_values(array_filter($clean, fn($v) => $v !== '')); + } + + return []; } /** - * Role 데이터가 Entity에 설정될 때 호출되어, 입력된 CSV/JSON 문자열을 정리 후 - * DB에 적합한 CSV 문자열로 최종 저장합니다. - * @param mixed $role 입력 데이터 (문자열 또는 배열) + * ✅ CI4 뮤테이터: "return 값"이 attributes에 저장됨 + * - 빈값이면 기존값 유지 (create에서 required면 validate에서 걸러짐) */ - public function setRole(mixed $role) + public function setPasswd($password): string + { + // null/'' 이면 기존값 유지 + if (!is_string($password) || $password === '') { + return (string) ($this->attributes['passwd'] ?? ''); + } + + return password_hash($password, PASSWORD_BCRYPT); + } + + /** + * ✅ role은 최종적으로 "CSV 문자열"로 저장 (DB 안전) + */ + public function setRole($role): string { $roleArray = []; - // 입력된 데이터가 문자열인 경우에만 trim 및 explode 처리 + if (is_string($role)) { - // trim()은 여기서 안전하게 호출됩니다. - $cleanRoleString = trim($role, " \t\n\r\0\x0B\""); - if (!empty($cleanRoleString)) { - // 문자열을 구분자로 분리하여 배열로 만듭니다. - $roleArray = explode(DEFAULTS["DELIMITER_COMMA"], $cleanRoleString); + $clean = trim($role, " \t\n\r\0\x0B\""); + if ($clean !== '') { + // JSON 문자열 가능성도 있어서 먼저 JSON 시도 + $decoded = json_decode($clean, true); + if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { + $roleArray = $decoded; + } else { + $roleArray = explode(DEFAULTS["DELIMITER_COMMA"], $clean); + } } - } - // 입력된 데이터가 이미 배열인 경우 (modify_process에서 $formDatas가 배열로 넘어옴) - elseif (is_array($role)) { + } elseif (is_array($role)) { $roleArray = $role; + } else { + // 그 외 타입은 안전하게 빈값 처리 + $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 문자열로 변환하여 저장합니다. - // ✅ setter함수는 반드시 attributes에 저장 - $this->attributes['role'] = implode(DEFAULTS["DELIMITER_COMMA"], $roleArray); + + $cleaned = array_map( + fn($item) => trim((string) ($item ?? ''), " \t\n\r\0\x0B\""), + $roleArray + ); + + $roleArray = array_values(array_filter($cleaned, fn($v) => $v !== '')); + + // ✅ 무조건 문자열 반환 (빈 배열이면 '') + return implode(DEFAULTS["DELIMITER_COMMA"], $roleArray); } } diff --git a/app/Forms/CommonForm.php b/app/Forms/CommonForm.php index 07c561d..c9fc7e3 100644 --- a/app/Forms/CommonForm.php +++ b/app/Forms/CommonForm.php @@ -4,9 +4,20 @@ namespace App\Forms; use RuntimeException; +/** + * CommonForm + * - 모든 Form의 공통 베이스 + * - 핵심 개선점: + * 1) FK/숫자 필드 미입력('')을 NULL로 정규화 ('' -> null) + * 2) 전역 null -> '' 변환 제거 (FK/숫자/날짜 타입 깨짐 방지) + * 3) validate()에서 dynamicRules 누적 버그 수정 (마지막 규칙만 남는 문제 해결) + * 4) "필드 존재 보장"으로 임의 '' 삽입 제거 (미입력 필드가 FK/숫자 규칙을 깨는 문제 방지) + * 5) role.* 같은 배열 원소 규칙을 위해 부모 배열 보정 로직 유지/강화 + */ abstract class CommonForm { private $_validation = null; + private array $_attributes = []; private array $_formFields = []; private array $_formRules = []; @@ -16,10 +27,12 @@ abstract class CommonForm private array $_formOptions = []; private array $_actionButtons = ['view' => ICONS['SEARCH'], 'delete' => ICONS['DELETE']]; private array $_batchjobButtons = ['batchjob' => '일괄처리', 'batchjob_delete' => '일괄삭제']; + protected function __construct() { $this->_validation = service('validation'); } + public function action_init_process(string $action, array &$formDatas = []): void { $actionButtons = ['view' => ICONS['SEARCH'], 'delete' => ICONS['DELETE']]; @@ -27,10 +40,12 @@ abstract class CommonForm $this->setActionButtons($actionButtons); $this->setBatchjobButtons($batchjobButtons); } + final public function setAttributes(array $attributes): void { $this->_attributes = $attributes; } + final public function getAttribute(string $key): string { if (!array_key_exists($key, $this->_attributes)) { @@ -38,21 +53,23 @@ abstract class CommonForm } return $this->_attributes[$key]; } + final public function setFormFields(array $fields): void { foreach ($fields as $field) { $this->_formFields[$field] = $this->getFormFieldLabel($field); } } - //$fields 매치된것만 반환, []->전체 + + // $fields 매치된것만 반환, []->전체 final public function getFormFields(array $fields = []): array { if (empty($fields)) { return $this->_formFields; } - // _formFields와 키를 비교하여 교집합을 반환합니다. $fields에 지정된 필드 정의만 추출됩니다. return array_intersect_key($this->_formFields, array_flip($fields)); } + public function setFormRules(string $action, array $fields, $formRules = []): void { foreach ($fields as $field) { @@ -60,6 +77,7 @@ abstract class CommonForm } $this->_formRules = $formRules; } + final public function getFormRules(array $fields = []): array { if (empty($fields)) { @@ -67,6 +85,7 @@ abstract class CommonForm } return array_intersect_key($this->_formRules, array_flip($fields)); } + final public function setFormOptions(string $action, array $fields, array $formDatas = [], $formOptions = []): void { foreach ($fields as $field) { @@ -74,7 +93,8 @@ abstract class CommonForm } $this->_formOptions = $formOptions; } - //$fields 매치된것만 반환, []->전체 + + // $fields 매치된것만 반환, []->전체 final public function getFormOptions(array $fields = []): array { if (empty($fields)) { @@ -82,163 +102,285 @@ abstract class CommonForm } return array_intersect_key($this->_formOptions, array_flip($fields)); } + final public function setFormFilters(array $fields): void { $this->_formFilters = $fields; } + final public function getFormFilters(): array { return $this->_formFilters; } + final public function setIndexFilters(array $fields): void { $this->_indexFilters = $fields; - ; } + final public function getIndexFilters(): array { return $this->_indexFilters; } + final public function setBatchjobFilters(array $fields): void { $this->_batchjobFilters = $fields; - ; } + final public function getBatchjobFilters(): array { return $this->_batchjobFilters; } + final public function setActionButtons(array $buttons): array { return $this->_actionButtons = $buttons; } + final public function getActionButtons(): array { return $this->_actionButtons; } + final public function setBatchjobButtons(array $buttons): array { return $this->_batchjobButtons = $buttons; } + final public function getBatchjobButtons(): array { return $this->_batchjobButtons; } - //Validation용 - /** - * 데이터를 검증하고 유효하지 않을 경우 예외를 발생시킵니다. - * 2025 CI4 표준: 규칙 배열 내에 label을 포함하여 한글 메시지 출력을 보장합니다. - * - * @param array $formDatas 검증할 데이터 - * @throws RuntimeException - */ + /* --------------------------------------------------------------------- + * Normalize / Sanitize + * --------------------------------------------------------------------- */ + + /** + * 1) 깊은 배열 구조 정리(배열은 유지) + * - 여기서는 null -> '' 같은 변환을 절대 하지 않습니다. + * - 이유: FK/숫자/날짜 필드가 ''로 변하면 validation/DB에서 문제가 발생함. + */ protected function sanitizeFormDatas($data, string $path = '') { - if (!is_array($data)) + if (!is_array($data)) { return $data; + } foreach ($data as $k => $v) { if (is_array($v)) { - $data[$k] = $this->sanitizeFormDatas($v, ($path !== '' ? "$path.$k" : (string) $k)); - } elseif ($v === null) { - $data[$k] = ''; + $data[$k] = $this->sanitizeFormDatas($v, ($path !== '' ? "{$path}.{$k}" : (string) $k)); } } + return $data; } + + /** + * 2) 숫자/FK 필드 정규화 + * - 폼에서 미선택은 보통 ''로 들어옴 -> NULL로 변환 + * - 숫자 문자열은 int 캐스팅 (선택) + * + * 주의: + * - "빈값을 0으로 취급" 같은 정책이 있다면 여기에서 조정해야 함. + */ + protected function normalizeNumericEmptyToNull(array $data, array $numericFields): array + { + foreach ($numericFields as $f) { + if (!array_key_exists($f, $data)) { + continue; + } + + if ($data[$f] === '') { + $data[$f] = null; + continue; + } + + if (is_string($data[$f]) && ctype_digit($data[$f])) { + $data[$f] = (int) $data[$f]; + } + } + + return $data; + } + + /** + * 3) role.* 같은 배열 원소 규칙이 있을 때, 부모 배열 존재/타입 보정 + */ + protected function ensureParentArrayForWildcardRules(array &$formDatas, array $formRules): void + { + foreach ($formRules as $fieldKey => $ruleDef) { + $fieldName = (string) $fieldKey; + + if (!str_contains($fieldName, '.*')) { + continue; + } + + $parent = str_replace('.*', '', $fieldName); + + // 1) 부모가 없거나 ''/null 이면 빈 배열 + if (!array_key_exists($parent, $formDatas) || $formDatas[$parent] === '' || $formDatas[$parent] === null) { + $formDatas[$parent] = []; + continue; + } + + // 2) 문자열이면 CSV로 분해 (혹시 JSON 문자열이면 JSON 우선) + if (is_string($formDatas[$parent])) { + $raw = trim($formDatas[$parent]); + if ($raw === '') { + $formDatas[$parent] = []; + } else { + $decoded = json_decode($raw, true); + if (json_last_error() === JSON_ERROR_NONE && is_array($decoded)) { + $formDatas[$parent] = $decoded; + } else { + $formDatas[$parent] = explode(DEFAULTS["DELIMITER_COMMA"], $raw); + } + } + } + + // 3) 배열이 아니면 강제 빈 배열 + if (!is_array($formDatas[$parent])) { + $formDatas[$parent] = []; + } + + // ✅ 4) 핵심: 배열 원소의 null/'' 제거 + 문자열화(Trim이 null 받지 않도록) + $clean = array_map( + fn($v) => is_scalar($v) ? trim((string) $v) : '', + $formDatas[$parent] + ); + $clean = array_values(array_filter($clean, fn($v) => $v !== '')); + + $formDatas[$parent] = $clean; + } + } + + + /** + * 4) 검증 rule에 따라 "numeric(특히 FK)"로 취급할 필드를 수집 + * - getFormRule()에서 permit_empty|numeric 로 정의되는 필드를 공통 처리하기 위함 + * + * 구현 전략: + * - formRules에서 rule 문자열에 'numeric'가 포함된 필드를 모음 + * - wildcard(role.*) 제외 + */ + protected function collectNumericFieldsFromRules(array $formRules): array + { + $numericFields = []; + + foreach ($formRules as $field => $rule) { + $fieldName = (string) $field; + + if (str_contains($fieldName, '.*')) { + continue; + } + + // getValidationRule hook 적용 (필드명/룰이 바뀔 수 있으니) + [$fieldName, $ruleStr] = $this->getValidationRule($fieldName, (string) $rule); + + if (is_string($ruleStr) && str_contains($ruleStr, 'numeric')) { + $numericFields[] = $fieldName; + } + } + + // 중복 제거 + return array_values(array_unique($numericFields)); + } + + /* --------------------------------------------------------------------- + * Validation + * --------------------------------------------------------------------- */ + + /** + * 데이터를 검증하고 유효하지 않을 경우 예외를 발생시킵니다. + * 2025 CI4 표준: 규칙 배열 내에 label을 포함하여 한글 메시지 출력을 보장합니다. + */ final public function validate(array &$formDatas): void { + log_message('debug', '>>> CommonForm::validate CALLED: ' . static::class); if ($this->_validation === null) { throw new RuntimeException(static::class . '->' . __FUNCTION__ . "에서 오류발생: Validation 서비스가 초기화되지 않았습니다."); } try { - //목적은 검증 전에 데이터 형태를 안전하게 정리 + // 0) 데이터 구조 정리 (null 변환 X) $formDatas = $this->sanitizeFormDatas($formDatas); - // 1. 필드 라벨/규칙 + + // 1) 필드 라벨/규칙 $formFields = $this->getFormFields(); $formRules = $this->getFormRules(); + if (empty($formRules)) { throw new RuntimeException(static::class . '->' . __FUNCTION__ . "에서 오류발생: 지정된 Form RULE이 없습니다."); } - // ✅ 1.5 배열 원소 규칙(role.*)이 존재할 경우, 부모 필드(role)가 없으면 빈 배열로 보장 - // 이렇게 해두면 엔진 내부에서 role.* 처리 중 trim(null) 류가 재발할 확률이 확 줄어듦 - foreach ($formRules as $fieldKey => $ruleDef) { - $fieldName = is_array($ruleDef) ? $fieldKey : $fieldKey; + // 2) wildcard(role.*) 부모 배열 보정 + $this->ensureParentArrayForWildcardRules($formDatas, $formRules); - // role.* 형태면 부모 role을 배열로 보장 - if (str_contains($fieldName, '.*')) { - $parent = str_replace('.*', '', $fieldName); - if (!array_key_exists($parent, $formDatas) || $formDatas[$parent] === '') { - $formDatas[$parent] = []; - } elseif (is_string($formDatas[$parent])) { - // 혹시 문자열로 들어오면 CSV → 배열 복원 (공통 방어막) - $formDatas[$parent] = ($formDatas[$parent] === '') ? [] : explode(DEFAULTS["DELIMITER_COMMA"], $formDatas[$parent]); - } elseif (!is_array($formDatas[$parent])) { - $formDatas[$parent] = []; - } - } - } + // 3) numeric(FK 포함) 필드: '' -> null, 숫자 문자열 -> int + // (규칙 기반 자동 수집) + $numericFields = $this->collectNumericFieldsFromRules($formRules); + $formDatas = $this->normalizeNumericEmptyToNull($formDatas, $numericFields); + // 4) dynamicRules 누적 구성 (버그 수정: 루프마다 초기화 금지) + $dynamicRules = []; foreach ($formRules as $field => $rule) { try { - // 2. 필드명/규칙 추출 - list($field, $rule) = $this->getValidationRule($field, $rule); + // 필드명/규칙 추출(확장 포인트) + [$fieldName, $ruleStr] = $this->getValidationRule((string) $field, (string) $rule); - // 3. label 결정 - if (isset($formFields[$field])) { - $label = $formFields[$field]; - } elseif (str_contains($field, '.*')) { - $parentField = str_replace('.*', '', $field); - $label = ($formFields[$parentField] ?? $field) . " 항목"; + // label 결정 + if (isset($formFields[$fieldName])) { + $label = $formFields[$fieldName]; + } elseif (str_contains($fieldName, '.*')) { + $parentField = str_replace('.*', '', $fieldName); + $label = ($formFields[$parentField] ?? $fieldName) . " 항목"; } else { - $label = $field; + $label = $fieldName; } - // 4. rules 설정 - $dynamicRules = []; - $dynamicRules[$field] = [ + $dynamicRules[$fieldName] = [ 'label' => $label, - 'rules' => $rule + 'rules' => $ruleStr, ]; - // ✅ 4.5 존재 보장 로직 수정 - // - 일반 필드: 없으면 '' 세팅 - // - 배열 원소 필드(role.*): 여기서 만들면 안 됨 (부모 role에서 처리해야 함) - if (!array_key_exists($field, $formDatas) && !str_contains($field, '.*')) { - $formDatas[$field] = ''; - } + // ❌ 존재 보장으로 '' 삽입하지 않음 + // - required는 CI4가 "키 없음"도 실패 처리 가능(일반적으로) + // - permit_empty는 키 없어도 통과 (강제로 '' 만들면 FK/숫자 문제 발생) } catch (\Throwable $e) { throw new RuntimeException("유효성 검사 규칙 준비 중 오류 발생 (필드: {$field}): " . $e->getMessage()); } } + $this->_validation->setRules($dynamicRules); + try { if (!$this->_validation->run($formDatas)) { $errors = $this->_validation->getErrors(); throw new RuntimeException(implode("\n", $errors)); } } catch (\TypeError $e) { - // 너의 상세 디버깅 로직은 그대로 둬도 됨 (생략 가능) throw new RuntimeException("검증 도중 타입 오류 발생: " . $e->getMessage()); } } catch (\Throwable $e) { - if ($e instanceof RuntimeException) + if ($e instanceof RuntimeException) { throw $e; + } throw new RuntimeException("유효성 검사 중 시스템 오류 발생: " . $e->getMessage()); } } + /* --------------------------------------------------------------------- + * Overridable hooks + * --------------------------------------------------------------------- */ - //필수함수 - //사용자정의 함수 + // 사용자 정의 hook: 필드/룰 커스터마이즈 protected function getValidationRule(string $field, string $rule): array { - return array($field, $rule); + return [$field, $rule]; } public function getFormFieldLabel(string $field, ?string $label = null): string @@ -251,22 +393,35 @@ abstract class CommonForm return $label; } + /** + * Form rule 정의 + * - permit_empty|numeric 인 FK들이 여기서 정의되면, + * validate()에서 자동으로 ''->null 정규화 대상에 포함됩니다. + */ public function getFormRule(string $action, string $field, array $formRules): array { switch ($field) { case $this->getAttribute('pk_field'): if (!$this->getAttribute('useAutoIncrement')) { - $formRules[$field] = sprintf("required|regex_match[/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/]%s", in_array($action, ["create"]) ? "|is_unique[{$this->getAttribute('table')}.{$field}]" : ""); + $formRules[$field] = sprintf( + "required|regex_match[/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/]%s", + in_array($action, ["create"]) ? "|is_unique[{$this->getAttribute('table')}.{$field}]" : "" + ); } else { $formRules[$field] = "required|numeric"; } break; case $this->getAttribute('title_field'): - $formRules[$field] = sprintf("required|trim|string%s", in_array($action, ["create", "create_form"]) ? "|is_unique[{$this->getAttribute('table')}.{$field}]" : ""); + $formRules[$field] = sprintf( + "required|trim|string%s", + in_array($action, ["create", "create_form"]) ? "|is_unique[{$this->getAttribute('table')}.{$field}]" : "" + ); break; case "code": - // a-zA-Z → 영문 대소문자,0-9 → 숫자,가-힣 → 한글 완성형,\- → 하이픈 - $formRules[$field] = sprintf("required|regex_match[/^[a-zA-Z0-9가-힣\-\_]+$/]|min_length[4]%s", in_array($action, ["create"]) ? "|is_unique[{$this->getAttribute('table')}.{$field}]" : ""); + $formRules[$field] = sprintf( + "required|regex_match[/^[a-zA-Z0-9가-힣\-\_]+$/]|min_length[4]%s", + in_array($action, ["create"]) ? "|is_unique[{$this->getAttribute('table')}.{$field}]" : "" + ); break; case "user_uid": $formRules[$field] = "required|numeric"; @@ -288,11 +443,18 @@ abstract class CommonForm $formRules[$field] = "permit_empty|trim|string"; break; } + return $formRules; } + + /* --------------------------------------------------------------------- + * Options + * --------------------------------------------------------------------- */ + protected function getFormOption_process($service, string $action, string $field, array $formDatas = []): array { $entities = []; + switch ($field) { default: if (in_array($action, ['create_form', 'modify_form', 'alternative_create_form'])) { @@ -307,12 +469,14 @@ abstract class CommonForm } break; } + return $entities; } public function getFormOption(string $action, string $field, array $formDatas = [], array $options = ['options' => [], 'atttributes' => []]): array { $tempOptions = ['' => lang("{$this->getAttribute('class_path')}.label.{$field}") . " 선택"]; + switch ($field) { case 'user_uid': foreach ($this->getFormOption_process(service('userservice'), $action, $field, $formDatas) as $entity) { @@ -320,12 +484,14 @@ abstract class CommonForm } $options['options'] = $tempOptions; break; + case 'clientinfo_uid': foreach ($this->getFormOption_process(service('customer_clientservice'), $action, $field, $formDatas) as $entity) { $tempOptions[$entity->getPK()] = $entity->getCustomTitle(); } $options['options'] = $tempOptions; break; + default: $optionDatas = lang($this->getAttribute('class_path') . "." . strtoupper($field)); if (!is_array($optionDatas)) { @@ -337,6 +503,7 @@ abstract class CommonForm $options['options'] = $tempOptions; break; } + return $options; } } diff --git a/app/Forms/Customer/ClientForm.php b/app/Forms/Customer/ClientForm.php index 11598b4..a02b8c3 100644 --- a/app/Forms/Customer/ClientForm.php +++ b/app/Forms/Customer/ClientForm.php @@ -16,6 +16,7 @@ class ClientForm extends CustomerForm 'email', 'phone', 'role', + 'status', ]; $filters = [ 'site', @@ -28,10 +29,6 @@ class ClientForm extends CustomerForm case 'create': case 'create_form': break; - case 'modify': - case 'modify_form': - $fields = [...$fields, 'status']; - break; case 'view': $fields = [...$fields, 'status', 'created_at']; break; @@ -69,8 +66,8 @@ class ClientForm extends CustomerForm $formRules[$field] = "required|trim|string"; break; case "role": - $formRules[$field] = 'required|is_array'; - $formRules['role.*'] = 'trim|in_list[user,vip,reseller]'; + $formRules[$field] = 'required|is_array|at_least_one'; + $formRules['role.*'] = 'permit_empty|trim|in_list[user,vip,reseller]'; break; case "email": $formRules[$field] = "permit_empty|trim|valid_email"; @@ -84,6 +81,9 @@ class ClientForm extends CustomerForm case "point_balance": $formRules[$field] = "permit_empty|numeric"; break; + case "status": + $formRules[$field] = "required|trim|string"; + break; default: $formRules = parent::getFormRule($action, $field, $formRules); break; diff --git a/app/Forms/UserForm.php b/app/Forms/UserForm.php index 76a4da6..4ff9d63 100644 --- a/app/Forms/UserForm.php +++ b/app/Forms/UserForm.php @@ -19,7 +19,8 @@ class UserForm extends CommonForm 'name', 'email', 'mobile', - 'role' + 'role', + 'status' ]; $filters = ['role', 'status']; $indexFilter = $filters; @@ -28,10 +29,6 @@ class UserForm extends CommonForm case 'create': case 'create_form': break; - case 'modify': - case 'modify_form': - $fields = [...$fields, 'status']; - break; case 'view': $fields = ['id', 'name', 'email', 'mobile', 'role', 'status', 'created_at']; break; @@ -63,8 +60,11 @@ class UserForm extends CommonForm $formRules[$field] = sprintf("required|trim|valid_email%s", in_array($action, ["create", "create_form"]) ? "|is_unique[{$this->getAttribute('table')}.{$field}]" : ""); break; case "role": - $formRules[$field] = 'required|is_array'; - $formRules['role.*'] = 'trim|in_list[manager,cloudflare,firewall,security,director,master]'; + $formRules[$field] = 'required|is_array|at_least_one'; + $formRules['role.*'] = 'permit_empty|trim|in_list[manager,cloudflare,firewall,security,director,master]'; + break; + case "status": + $formRules[$field] = "required|trim|string"; break; default: $formRules = parent::getFormRule($action, $field, $formRules); diff --git a/app/Language/ko/Validation.php b/app/Language/ko/Validation.php index 9bb1dc1..5be2db7 100644 --- a/app/Language/ko/Validation.php +++ b/app/Language/ko/Validation.php @@ -4,35 +4,33 @@ return [ // 여기서부터 각 Validation rule에 대한 메시지를 정의합니다. // {field}나 {param} 같은 플레이스홀더는 그대로 유지해야 합니다. - - 'required' => '[{field}] 필수 입력 항목입니다.', - 'isset' => '[{field}] 값이 반드시 있어야 합니다.', - 'valid_email' => '[{field}] 유효한 이메일 주소여야 합니다.', - 'valid_url' => '[{field}] 유효한 URL이어야 합니다.', - 'valid_date' => '[{field}] 유효한 날짜여야 합니다.', - 'valid_dates' => '[{field}] 유효한 날짜여야 합니다.', - 'valid_ip' => '[{field}] 유효한 IP 주소여야 합니다.', - 'valid_mac' => '[{field}] 유효한 MAC 주소여야 합니다.', - 'numeric' => '[{field}] 숫자만 포함해야 합니다.', - 'integer' => '[{field}] 정수여야 합니다.', - 'decimal' => '[{field}] 소수점 숫자여야 합니다.', - 'is_numeric' => '[{field}] 숫자 문자만 포함해야 합니다.', - 'regex_match' => '[{field}] 올바른 형식이어야 합니다.', - 'matches' => '{field} 필드가 {param} 필드와 일치하지 않습니다.', - 'differs' => '[{field}] {param} 필드와 달라야 합니다.', - 'is_unique' => '[{field}] 고유한 값이어야 합니다.', - 'is_natural' => '[{field}] 숫자여야 합니다.', - 'is_natural_no_zero' => '[{field}] 0보다 큰 숫자여야 합니다.', - 'less_than' => '[{field}] {param}보다 작아야 합니다.', - 'less_than_equal_to' => '[{field}] {param}보다 작거나 같아야 합니다.', - 'greater_than' => '[{field}] {param}보다 커야 합니다.', - 'greater_than_equal_to' => '[{field}] {param}보다 크거나 같아야 합니다.', - 'error_prefix' => '', - 'error_suffix' => '', - - // 길이(Length) 관련 rule 메시지 - 'min_length' => '[{field}] 최소 {param}자 이상이어야 합니다.', - 'max_length' => '[{field}] 최대 {param}자 이하여야 합니다.', - 'exact_length' => '[{field}] 정확히 {param}자여야 합니다.', - 'in_list' => '[{field}] 다음 중 하나여야 합니다: {param}.', + 'required' => '[{field}] 필수 입력 항목입니다.', + 'isset' => '[{field}] 값이 반드시 있어야 합니다.', + 'valid_email' => '[{field}] 유효한 이메일 주소여야 합니다.', + 'valid_url' => '[{field}] 유효한 URL이어야 합니다.', + 'valid_date' => '[{field}] 유효한 날짜여야 합니다.', + 'valid_dates' => '[{field}] 유효한 날짜여야 합니다.', + 'valid_ip' => '[{field}] 유효한 IP 주소여야 합니다.', + 'valid_mac' => '[{field}] 유효한 MAC 주소여야 합니다.', + 'numeric' => '[{field}] 숫자만 포함해야 합니다.', + 'integer' => '[{field}] 정수여야 합니다.', + 'decimal' => '[{field}] 소수점 숫자여야 합니다.', + 'is_numeric' => '[{field}] 숫자 문자만 포함해야 합니다.', + 'regex_match' => '[{field}] 올바른 형식이어야 합니다.', + 'matches' => '{field} 필드가 {param} 필드와 일치하지 않습니다.', + 'differs' => '[{field}] {param} 필드와 달라야 합니다.', + 'is_unique' => '[{field}] 고유한 값이어야 합니다.', + 'is_natural' => '[{field}] 숫자여야 합니다.', + 'is_natural_no_zero' => '[{field}] 0보다 큰 숫자여야 합니다.', + 'less_than' => '[{field}] {param}보다 작아야 합니다.', + 'less_than_equal_to' => '[{field}] {param}보다 작거나 같아야 합니다.', + 'greater_than' => '[{field}] {param}보다 커야 합니다.', + 'greater_than_equal_to' => '[{field}] {param}보다 크거나 같아야 합니다.', + 'error_prefix' => '', + 'error_suffix' => '', + 'min_length' => '[{field}] 최소 {param}자 이상이어야 합니다.', + 'max_length' => '[{field}] 최대 {param}자 이하여야 합니다.', + 'exact_length' => '[{field}] 정확히 {param}자여야 합니다.', + 'in_list' => '[{field}] 다음 중 하나여야 합니다: {param}.', + 'at_least_one' => '{field} 최소 1개 이상 선택해야 합니다.', ]; diff --git a/app/Services/CommonService.php b/app/Services/CommonService.php index de43a2d..4ef3dac 100644 --- a/app/Services/CommonService.php +++ b/app/Services/CommonService.php @@ -2,17 +2,14 @@ namespace App\Services; +use App\Forms\CommonForm; use App\DTOs\CommonDTO; use App\Entities\CommonEntity; -use App\Libraries\AuthContext; use App\Models\CommonModel; +use App\Libraries\AuthContext; use CodeIgniter\Database\Exceptions\DatabaseException; use RuntimeException; -/** - * @template TEntity of CommonEntity - * @template TDto of CommonDTO - */ abstract class CommonService { private ?AuthContext $_authContext = null; @@ -239,6 +236,8 @@ abstract class CommonService // INSERT 시 Entity의 PK는 0 또는 NULL이어야 함 (DB가 ID를 생성하도록) $initialPK = $entity->getPK(); $result = $this->model->save($entity); + log_message('debug', __FUNCTION__ . ":" . var_export($entity, true)); + log_message('debug', __FUNCTION__ . ":" . $this->model->getLastQuery()); // 최종적으로 DB에 반영된 PK를 반환받습니다. (UPDATE이면 기존 PK, INSERT이면 새 PK) $entity->{$this->getPKField()} = $this->handle_save_result($result, $initialPK); // handle_save_result에서 확인된 최종 PK를 사용하여 DB에서 최신 엔티티를 가져옴 @@ -260,13 +259,18 @@ abstract class CommonService protected function create_process(array $formDatas): CommonEntity { try { + log_message('debug', "*** ENTER" . __METHOD__ . " ***"); $actionForm = $this->getActionForm(); + log_message('debug', 'FORMCLASS=' . $this->formClass . ' / FORMINST=' . (is_object($actionForm) ? get_class($actionForm) : 'NULL')); + log_message('debug', 'IS_COMMONFORM=' . (is_object($actionForm) && $actionForm instanceof CommonForm ? 'YES' : 'NO')); if ($actionForm instanceof CommonForm) { $actionForm->action_init_process('create', $formDatas); foreach ($formDatas as $field => $value) { $formDatas = $this->action_process_fieldhook($field, $value, $formDatas); } - $actionForm->validate($formDatas); + log_message('debug', '>>> BEFORE validate: ' . get_class($actionForm)); + $actionForm->validate($formDatas); // ✅ 여기서 검증 + log_message('debug', '>>> AFTER validate'); } $entityClass = $this->getEntityClass(); $entity = new $entityClass($formDatas); @@ -291,13 +295,18 @@ abstract class CommonService protected function modify_process($entity, array $formDatas): CommonEntity { try { + log_message('debug', "*** ENTER" . __METHOD__ . " ***"); $actionForm = $this->getActionForm(); + log_message('debug', 'FORMCLASS=' . $this->formClass . ' / FORMINST=' . (is_object($actionForm) ? get_class($actionForm) : 'NULL')); + log_message('debug', 'IS_COMMONFORM=' . (is_object($actionForm) && $actionForm instanceof CommonForm ? 'YES' : 'NO')); if ($actionForm instanceof CommonForm) { $actionForm->action_init_process('modify', $formDatas); foreach ($formDatas as $field => $value) { $formDatas = $this->action_process_fieldhook($field, $value, $formDatas); } + log_message('debug', '>>> BEFORE validate: ' . get_class($actionForm)); $actionForm->validate($formDatas); // ✅ 여기서 검증 + log_message('debug', '>>> AFTER validate'); } // 검증 통과 후 엔티티 반영 $entity->fill($formDatas); diff --git a/app/Validation/CustomRules.php b/app/Validation/CustomRules.php new file mode 100644 index 0000000..8f64a34 --- /dev/null +++ b/app/Validation/CustomRules.php @@ -0,0 +1,32 @@ + is_scalar($v) ? trim((string) $v) : '', + $value + ), fn($v) => $v !== '')); + return count($clean) >= 1; + } + + if (is_string($value)) { + $v = trim($value); + if ($v === '') + return false; + + $parts = array_values(array_filter(array_map( + fn($x) => trim((string) $x), + explode(DEFAULTS["DELIMITER_COMMA"], $v) + ), fn($x) => $x !== '')); + + return count($parts) >= 1; + } + + return false; + } +}