dbmsv4 init...5

This commit is contained in:
최준흠 2026-02-09 11:15:16 +09:00
parent 509cd0e999
commit 40b809949c
9 changed files with 465 additions and 217 deletions

View File

@ -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<string, string>
*/
public array $templates = [
'list' => 'CodeIgniter\Validation\Views\list',
'list' => 'CodeIgniter\Validation\Views\list',
'single' => 'CodeIgniter\Validation\Views\single',
];

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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;

View File

@ -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);

View File

@ -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개 이상 선택해야 합니다.',
];

View File

@ -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);

View File

@ -0,0 +1,32 @@
<?php
namespace App\Validation;
class CustomRules
{
public function at_least_one($value, ?string $params = null, array $data = []): bool
{
if (is_array($value)) {
$clean = array_values(array_filter(array_map(
fn($v) => 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;
}
}