AI 단계별 작업 문서 01 — 1단계: 헬퍼 파일 설치
이 문서에는 헬퍼 파일 설치 설명과 원본 PHP 코드 전체가 포함됩니다. 코드를 임의로 생략하거나 재작성하지 말고, 실제 프로젝트 소스와 대조한 뒤 적용하세요.
3. 1단계 — 헬퍼 파일 설치 (코어 수정 없음)
extend/top/60_board_fields_helpers.php 를 새로 만든다.
- 파일명은
50_board_fields.php보다 뒤(60_이상)여야 한다. (extend/top/은 파일명 오름차순으로 자동 실행) - 이 파일은 DB 구조를 바꾸지 않는다. 삭제하면 원상복구된다.
- 아래 코드는 PHP 5.6 호환 문법(
array(),isset())만 사용한다. (실행 테스트는 PHP 8.3)
| 함수 | 용도 |
|---|---|
dx_bf_list_fields($boardId) |
is_list=1 이고 활성인 필드 정의 |
dx_bf_meta_map($posts) |
글 배열의 값을 쿼리 1회로 조회 → [글ID][key]=값 |
dx_bf_display($field, $value) |
값 → 목록용 HTML (이스케이프·타입별 표시) |
dx_bf_build_filter($boardId, $_GET) |
is_search=1 필드의 GET 파라미터 → SQL 조건 |
dx_bf_render_filter_form(...) |
필터 폼 HTML 생성 |
<?php
/**
* DXCMS — 여분 필드 보조 헬퍼 (목록 노출 · 검색 필터)
* extend/top/60_board_fields_helpers.php
*
* - 파일명은 반드시 50_board_fields.php 보다 뒤(60_ 이상)여야 합니다. (오름차순 로드)
* - PHP 5.6 호환 문법만 사용합니다.
* - 이 파일은 DB 구조를 바꾸지 않습니다. 삭제하면 원상복구됩니다.
*/
if (!defined('DX_CMS')) exit('Direct access not allowed.');
/* ══ 1. 목록 표시용 ═════════════════════════════════════════ */
/** 목록에 노출할 필드 정의 (활성 + is_list=1, 정렬순) */
if (!function_exists('dx_bf_list_fields')) {
function dx_bf_list_fields($boardId)
{
if (!function_exists('dx_board_fields')) return array();
$out = array();
foreach (dx_board_fields()->getFields((int)$boardId) as $f) {
if ((int)$f['is_list'] === 1) $out[] = $f;
}
return $out;
}
}
/**
* 글 배열의 여분 필드 값을 한 번에 조회 (N+1 방지)
* 반환: array('글ID' => array('field_key' => 'value', ...), ...)
* 사용: $metaMap = dx_bf_meta_map(array_merge($notices, $posts));
*/
if (!function_exists('dx_bf_meta_map')) {
function dx_bf_meta_map($posts)
{
$map = array();
if (empty($posts)) return $map;
$ids = array();
foreach ($posts as $p) {
if (isset($p['id'])) $ids[] = (string)$p['id'];
}
$ids = array_values(array_unique($ids));
if (!$ids) return $map;
$db = Database::getInstance();
$tbl = $db->table('post_meta');
foreach (array_chunk($ids, 200) as $chunk) {
$in = implode(',', array_fill(0, count($chunk), '?'));
$rows = $db->rows(
"SELECT post_id, field_key, value FROM `{$tbl}` WHERE post_id IN ($in)",
$chunk
);
foreach ($rows as $r) {
$map[(string)$r['post_id']][$r['field_key']] = $r['value'];
}
}
return $map;
}
}
/** 값 1개를 목록용 HTML 로 변환 (항상 이스케이프됨) */
if (!function_exists('dx_bf_display')) {
function dx_bf_display($field, $value)
{
if ($value === null || $value === '') return '';
$e = htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
switch ($field['field_type']) {
case 'checkbox':
$o = '';
foreach (explode(',', $value) as $v) {
$v = trim($v);
if ($v === '') continue;
$o .= '<span class="dx-bf-chip" style="display:inline-block;margin:0 4px 2px 0;padding:1px 8px;'
. 'border-radius:999px;background:var(--bg-hover,#f1f5f9);font-size:.72rem">'
. htmlspecialchars($v, ENT_QUOTES, 'UTF-8') . '</span>';
}
return $o;
case 'number':
return (is_numeric($value) && strpos($value, '.') === false && strlen($value) <= 15)
? number_format((float)$value) : $e;
case 'url':
return preg_match('#^https?://#i', $value)
? '<a href="' . $e . '" target="_blank" rel="noopener">링크</a>' : $e;
case 'datetime':
return htmlspecialchars(str_replace('T', ' ', $value), ENT_QUOTES, 'UTF-8');
default:
return $e;
}
}
}
/* ══ 2. 검색 필터 — SQL 조건 생성 ═══════════════════════════
*
* URL 규격 (is_search=1 인 활성 필드만 허용 — 그 외 파라미터는 무시)
* bf_{key}=값 정확히 일치 (select/radio/text/date ...)
* bf_{key}[]=A&bf_{key}[]=B 체크박스: 선택한 항목을 모두 포함(AND)
* bfmin_{key}, bfmax_{key} number/date/datetime 범위 (이상/이하)
* bfq_{key}=검색어 text/textarea/tel/url 부분 일치(LIKE)
*
* 반환:
* sql → ' AND EXISTS (...) ...' ($where 뒤에 이어붙임, 별칭 p = dx_posts)
* params → sql 의 ? 순서대로의 값 (필드 key 도 바인딩됨)
* active → 하나라도 적용되면 true
* applied → array(array('key','label','display'), ...) (칩 표시용)
* qs → '&bf_...=...' 페이지네이션/링크에 붙일 쿼리스트링
*/
if (!function_exists('dx_bf_build_filter')) {
function dx_bf_build_filter($boardId, $src = null)
{
$res = array('sql' => '', 'params' => array(), 'active' => false, 'applied' => array(), 'qs' => '');
if ($src === null) $src = $_GET;
if (!is_array($src) || !function_exists('dx_board_fields')) return $res;
$db = Database::getInstance();
$tPm = $db->table('post_meta');
$q = array();
foreach (dx_board_fields()->getFields((int)$boardId) as $f) {
if ((int)$f['is_search'] !== 1) continue;
$k = $f['field_key'];
$type = $f['field_type'];
$cond = '';
$cp = array();
$show = '';
/* ① 정확 일치 / 체크박스 포함 */
$n = 'bf_' . $k;
if (isset($src[$n])) {
$vals = is_array($src[$n]) ? $src[$n] : array($src[$n]);
$clean = array();
foreach ($vals as $v) {
if (!is_scalar($v)) continue;
$v = trim((string)$v);
if ($v === '' || mb_strlen($v, 'UTF-8') > 255) continue;
$clean[] = $v;
if (count($clean) >= 20) break;
}
if ($clean) {
if ($type === 'checkbox') {
foreach ($clean as $v) { $cond .= ' AND FIND_IN_SET(?, pm.`value`)'; $cp[] = $v; }
$q[$n] = $clean;
} else {
$cond .= ' AND pm.`value` = ?';
$cp[] = $clean[0];
$q[$n] = $clean[0];
$clean = array($clean[0]);
}
$show = implode(', ', $clean);
}
}
/* ② 범위 (number / date / datetime) */
if (in_array($type, array('number', 'date', 'datetime'), true)) {
$ops = array('min' => '>=', 'max' => '<=');
foreach ($ops as $kind => $op) {
$rn = 'bf' . $kind . '_' . $k;
if (!isset($src[$rn]) || !is_scalar($src[$rn])) continue;
$rv = trim((string)$src[$rn]);
if ($rv === '') continue;
if ($type === 'number') {
if (!is_numeric($rv)) continue;
$cond .= " AND pm.`value` <> '' AND CAST(pm.`value` AS DECIMAL(20,4)) $op ?";
$cp[] = $rv;
} else {
if (!preg_match('/^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2})?$/', $rv)) continue;
$cmp = $rv;
if ($type === 'datetime' && strlen($rv) === 10) {
// 날짜만 입력하면 그 날의 시작/끝으로 보정
$cmp = $rv . ($kind === 'min' ? 'T00:00' : 'T23:59');
}
$cond .= " AND pm.`value` <> '' AND pm.`value` $op ?";
$cp[] = $cmp;
}
$q[$rn] = $rv;
$show .= ($show !== '' ? ' / ' : '') . ($kind === 'min' ? '≥ ' : '≤ ') . $rv;
}
}
/* ③ 부분 일치 (text / textarea / tel / url) */
if (in_array($type, array('text', 'textarea', 'tel', 'url'), true)) {
$ln = 'bfq_' . $k;
if (isset($src[$ln]) && is_scalar($src[$ln])) {
$lv = trim((string)$src[$ln]);
if ($lv !== '' && mb_strlen($lv, 'UTF-8') <= 100) {
$cond .= ' AND pm.`value` LIKE ?';
$cp[] = '%' . addcslashes($lv, '%_\\') . '%';
$q[$ln] = $lv;
$show .= ($show !== '' ? ' / ' : '') . '"' . $lv . '"';
}
}
}
if ($cond !== '') {
$res['sql'] .= " AND EXISTS (SELECT 1 FROM `{$tPm}` pm"
. " WHERE pm.`post_id` = p.`id` AND pm.`field_key` = ?" . $cond . ")";
$res['params'][] = $k;
foreach ($cp as $v) { $res['params'][] = $v; }
$res['applied'][] = array('key' => $k, 'label' => $f['field_label'], 'display' => $show);
$res['active'] = true;
}
}
if ($q) $res['qs'] = '&' . http_build_query($q);
return $res;
}
}
/* ══ 3. 검색 필터 — 폼 HTML 생성 ════════════════════════════ */
/**
* is_search=1 필드의 필터 폼 HTML
* $action : 폼 action URL (목록 URL)
* $keep : 유지할 GET 값 예) array('cat'=>$cat, 'parent'=>$p, 's'=>$search, 'sf'=>$sf)
* $src : 현재 선택값을 읽을 배열 (기본 $_GET)
* 검색 가능 필드가 없으면 빈 문자열.
*/
if (!function_exists('dx_bf_render_filter_form')) {
function dx_bf_render_filter_form($boardId, $action, array $keep = array(), $src = null)
{
if ($src === null) $src = $_GET;
if (!function_exists('dx_board_fields')) return '';
$fields = array();
foreach (dx_board_fields()->getFields((int)$boardId) as $f) {
if ((int)$f['is_search'] === 1) $fields[] = $f;
}
if (!$fields) return '';
$h = function ($s) { return htmlspecialchars((string)$s, ENT_QUOTES, 'UTF-8'); };
$css = 'padding:7px 10px;border:1px solid var(--border,#e2e8f0);border-radius:8px;font-size:.82rem;'
. 'background:var(--bg-body,#fff);color:var(--text-main,#111);font-family:inherit;box-sizing:border-box';
$opts = function ($raw) {
$o = array();
foreach (explode("\n", str_replace("\r", '', (string)$raw)) as $line) {
$line = trim($line);
if ($line !== '') $o[] = $line;
}
return $o;
};
$html = '<form method="get" action="' . $h($action) . '" class="dx-bf-filter" '
. 'style="display:flex;flex-wrap:wrap;gap:10px 14px;align-items:flex-end;margin:0 0 16px">';
foreach ($keep as $name => $val) {
if ($val === '' || $val === null) continue;
$html .= '<input type="hidden" name="' . $h($name) . '" value="' . $h($val) . '">';
}
foreach ($fields as $f) {
$k = $f['field_key'];
$type = $f['field_type'];
$lab = '<span style="display:block;font-size:.72rem;font-weight:700;color:var(--text-muted,#888);margin-bottom:4px">'
. $h($f['field_label']) . '</span>';
$html .= '<label style="display:block">' . $lab;
if ($type === 'select' || $type === 'radio') {
$cur = isset($src['bf_' . $k]) && is_scalar($src['bf_' . $k]) ? (string)$src['bf_' . $k] : '';
$html .= '<select name="bf_' . $h($k) . '" style="' . $css . '"><option value="">전체</option>';
foreach ($opts($f['field_options']) as $o) {
$html .= '<option value="' . $h($o) . '"' . ($cur === $o ? ' selected' : '') . '>' . $h($o) . '</option>';
}
$html .= '</select>';
} elseif ($type === 'checkbox') {
$cur = array();
if (isset($src['bf_' . $k])) {
$cur = is_array($src['bf_' . $k]) ? $src['bf_' . $k] : array($src['bf_' . $k]);
}
$html .= '<span style="display:flex;flex-wrap:wrap;gap:4px 12px;font-size:.82rem">';
foreach ($opts($f['field_options']) as $o) {
$html .= '<span style="display:inline-flex;align-items:center;gap:4px">'
. '<input type="checkbox" name="bf_' . $h($k) . '[]" value="' . $h($o) . '"'
. (in_array($o, $cur, true) ? ' checked' : '') . '>' . $h($o) . '</span>';
}
$html .= '</span>';
} elseif ($type === 'number' || $type === 'date' || $type === 'datetime') {
$it = ($type === 'number') ? 'number' : 'date';
$vmin = isset($src['bfmin_' . $k]) && is_scalar($src['bfmin_' . $k]) ? substr((string)$src['bfmin_' . $k], 0, 10) : '';
$vmax = isset($src['bfmax_' . $k]) && is_scalar($src['bfmax_' . $k]) ? substr((string)$src['bfmax_' . $k], 0, 10) : '';
if ($type === 'number') {
$vmin = isset($src['bfmin_' . $k]) && is_scalar($src['bfmin_' . $k]) ? (string)$src['bfmin_' . $k] : '';
$vmax = isset($src['bfmax_' . $k]) && is_scalar($src['bfmax_' . $k]) ? (string)$src['bfmax_' . $k] : '';
}
$html .= '<span style="display:inline-flex;align-items:center;gap:6px">'
. '<input type="' . $it . '" name="bfmin_' . $h($k) . '" value="' . $h($vmin) . '" placeholder="최소" style="' . $css . ';width:120px">'
. '~'
. '<input type="' . $it . '" name="bfmax_' . $h($k) . '" value="' . $h($vmax) . '" placeholder="최대" style="' . $css . ';width:120px">'
. '</span>';
} else { // text / textarea / tel / url → 부분 일치
$cur = isset($src['bfq_' . $k]) && is_scalar($src['bfq_' . $k]) ? (string)$src['bfq_' . $k] : '';
$html .= '<input type="text" name="bfq_' . $h($k) . '" value="' . $h($cur) . '" style="' . $css . '">';
}
$html .= '</label>';
}
$reset = $action;
$rq = array();
foreach ($keep as $name => $val) { if ($val !== '' && $val !== null) $rq[$name] = $val; }
if ($rq) $reset .= (strpos($action, '?') === false ? '?' : '&') . http_build_query($rq);
$html .= '<span style="display:inline-flex;gap:6px">'
. '<button type="submit" style="' . $css . ';background:var(--p,#3b82f6);color:#fff;border-color:transparent;font-weight:700;cursor:pointer">필터 적용</button>'
. '<a href="' . $h($reset) . '" style="' . $css . ';text-decoration:none;display:inline-block">초기화</a>'
. '</span></form>';
return $html;
}
}
설치 후 확인: 아무 페이지나 열어 오류가 없으면 정상이다. (50_board_fields.php 가 없으면 dx_board_fields() 가 없어 모든 함수가 빈 결과를 반환하도록 방어되어 있다.)