From 59b33ccb4239a6f48fa1213371683511dbae0e4f Mon Sep 17 00:00:00 2001 From: taekyoungc Date: Tue, 16 Jun 2026 15:39:02 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=9D=98?= =?UTF-8?q?=EA=B2=AC(=ED=94=BC=EB=93=9C=EB=B0=B1)=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=E2=80=94=20=ED=99=94=EB=A9=B4=20=EC=BA=A1=EC=B2=98+=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EC=B2=A8=EB=B6=80,=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=EA=B2=80=ED=86=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 전 화면 우하단 '의견' 위젯(embed/portal/admin 레이아웃) — 로그인 사용자 - 의견 + html2canvas 화면 캡처 + 이미지 첨부(최대 5장) 저장 - 화면 URL/계정/지자체 자동 수집(계정·지자체는 서버 세션 기준) - 관리자 /admin/feedback 목록·상세·상태(접수/처리중/완료/보류)·메모, 테넌트 분리 - feedback/feedback_file 테이블, 첨부 이미지는 관리자 전용 라우트로 서빙 Co-Authored-By: Claude Opus 4.8 --- app/Config/Routes.php | 10 ++ app/Controllers/Admin/Feedback.php | 145 ++++++++++++++++++++++ app/Controllers/Feedback.php | 98 +++++++++++++++ app/Models/FeedbackFileModel.php | 23 ++++ app/Models/FeedbackModel.php | 40 ++++++ app/Views/admin/feedback/index.php | 66 ++++++++++ app/Views/admin/feedback/show.php | 64 ++++++++++ app/Views/admin/layout.php | 1 + app/Views/bag/layout/embed.php | 1 + app/Views/bag/layout/portal.php | 1 + app/Views/components/feedback_widget.php | 144 +++++++++++++++++++++ writable/database/feedback_file_table.sql | 10 ++ writable/database/feedback_table.sql | 21 ++++ 13 files changed, 624 insertions(+) create mode 100644 app/Controllers/Admin/Feedback.php create mode 100644 app/Controllers/Feedback.php create mode 100644 app/Models/FeedbackFileModel.php create mode 100644 app/Models/FeedbackModel.php create mode 100644 app/Views/admin/feedback/index.php create mode 100644 app/Views/admin/feedback/show.php create mode 100644 app/Views/components/feedback_widget.php create mode 100644 writable/database/feedback_file_table.sql create mode 100644 writable/database/feedback_table.sql diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 0259e17..2c536d7 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -221,6 +221,9 @@ $routes->group('bag', ['filter' => 'adminAuth'], static function ($routes): void $routes->post('password-change', 'Admin\PasswordChange::update'); }); +// 사용자 의견 접수 (로그인 사용자) +$routes->post('feedback', 'Feedback::store', ['filter' => 'loginAuth']); + // Auth $routes->get('login', 'Auth::showLoginForm'); $routes->post('login', 'Auth::login'); @@ -236,6 +239,13 @@ $routes->post('register', 'Auth::register'); $routes->group('admin', ['filter' => 'adminAuth'], static function ($routes): void { $routes->get('select-local-government', 'Admin\SelectLocalGovernment::index'); $routes->post('select-local-government', 'Admin\SelectLocalGovernment::store'); + + // 사용자 의견 검토 + $routes->get('feedback', 'Admin\Feedback::index'); + $routes->get('feedback/image/(:num)', 'Admin\Feedback::image/$1'); + $routes->get('feedback/file/(:num)', 'Admin\Feedback::file/$1'); + $routes->get('feedback/(:num)', 'Admin\Feedback::show/$1'); + $routes->post('feedback/(:num)/status', 'Admin\Feedback::updateStatus/$1'); $routes->get('/', 'Admin\Dashboard::index'); $routes->get('users', 'Admin\User::index'); $routes->get('users/create', 'Admin\User::create'); diff --git a/app/Controllers/Admin/Feedback.php b/app/Controllers/Admin/Feedback.php new file mode 100644 index 0000000..2877d14 --- /dev/null +++ b/app/Controllers/Admin/Feedback.php @@ -0,0 +1,145 @@ +get('mb_level'); + if (Roles::isSuperAdminEquivalent($level)) { + return null; // 전체 + } + + return admin_effective_lg_idx(); + } + + public function index(): string + { + $model = model(FeedbackModel::class); + $builder = $model->orderBy('fb_idx', 'DESC'); + + $scope = $this->scopeLgIdx(); + if ($scope !== null) { + $builder->where('fb_lg_idx', $scope); + } + + $status = (string) ($this->request->getGet('status') ?? ''); + if (in_array($status, FeedbackModel::STATUSES, true)) { + $builder->where('fb_status', $status); + } + + $list = $builder->paginate(20); + $pager = $model->pager; + + return view('admin/layout', [ + 'title' => '사용자 의견', + 'content' => view('admin/feedback/index', [ + 'list' => $list, + 'pager' => $pager, + 'status' => $status, + ]), + ]); + } + + public function show(int $id): string|ResponseInterface + { + $model = model(FeedbackModel::class); + $row = $model->find($id); + $scope = $this->scopeLgIdx(); + if ($row === null || ($scope !== null && (int) $row->fb_lg_idx !== $scope)) { + return redirect()->to(site_url('admin/feedback'))->with('error', '의견을 찾을 수 없습니다.'); + } + + $files = model(FeedbackFileModel::class) + ->where('ff_fb_idx', $id) + ->orderBy('ff_idx', 'ASC') + ->findAll(); + + return view('admin/layout', [ + 'title' => '의견 상세', + 'content' => view('admin/feedback/show', ['row' => $row, 'files' => $files]), + ]); + } + + public function updateStatus(int $id): ResponseInterface + { + $model = model(FeedbackModel::class); + $row = $model->find($id); + $scope = $this->scopeLgIdx(); + if ($row === null || ($scope !== null && (int) $row->fb_lg_idx !== $scope)) { + return redirect()->to(site_url('admin/feedback'))->with('error', '의견을 찾을 수 없습니다.'); + } + + $status = (string) $this->request->getPost('fb_status'); + $memo = (string) $this->request->getPost('fb_admin_memo'); + $update = ['fb_admin_memo' => mb_substr($memo, 0, 2000)]; + if (in_array($status, FeedbackModel::STATUSES, true)) { + $update['fb_status'] = $status; + } + $model->update($id, $update); + + return redirect()->to(site_url('admin/feedback/' . $id))->with('success', '처리 상태를 저장했습니다.'); + } + + /** 스크린샷 이미지 서빙 (관리자만, writable 보호 유지) */ + public function image(int $id): ResponseInterface + { + $model = model(FeedbackModel::class); + $row = $model->find($id); + $scope = $this->scopeLgIdx(); + if ($row === null || ($scope !== null && (int) $row->fb_lg_idx !== $scope) || empty($row->fb_screenshot)) { + return $this->response->setStatusCode(404)->setBody('not found'); + } + + $path = WRITEPATH . 'uploads/feedback/' . basename((string) $row->fb_screenshot); + if (! is_file($path)) { + return $this->response->setStatusCode(404)->setBody('not found'); + } + + $mime = str_ends_with($path, '.png') ? 'image/png' : 'image/jpeg'; + + return $this->response + ->setHeader('Content-Type', $mime) + ->setHeader('Cache-Control', 'private, max-age=300') + ->setBody((string) file_get_contents($path)); + } + + /** 첨부 이미지(feedback_file) 서빙 — 관리자만, 테넌트 검증 */ + public function file(int $ffIdx): ResponseInterface + { + $ff = model(FeedbackFileModel::class)->find($ffIdx); + if ($ff === null) { + return $this->response->setStatusCode(404)->setBody('not found'); + } + $fb = model(FeedbackModel::class)->find((int) $ff->ff_fb_idx); + $scope = $this->scopeLgIdx(); + if ($fb === null || ($scope !== null && (int) $fb->fb_lg_idx !== $scope)) { + return $this->response->setStatusCode(404)->setBody('not found'); + } + + $path = WRITEPATH . 'uploads/feedback/' . basename((string) $ff->ff_path); + if (! is_file($path)) { + return $this->response->setStatusCode(404)->setBody('not found'); + } + + $mime = (string) ($ff->ff_mime ?: (str_ends_with($path, '.png') ? 'image/png' : 'image/jpeg')); + + return $this->response + ->setHeader('Content-Type', $mime) + ->setHeader('Cache-Control', 'private, max-age=300') + ->setBody((string) file_get_contents($path)); + } +} diff --git a/app/Controllers/Feedback.php b/app/Controllers/Feedback.php new file mode 100644 index 0000000..aca7950 --- /dev/null +++ b/app/Controllers/Feedback.php @@ -0,0 +1,98 @@ +get('logged_in')) { + return $this->response->setStatusCode(401)->setJSON(['ok' => false, 'message' => '로그인이 필요합니다.']); + } + + $content = trim((string) $this->request->getPost('content')); + if ($content === '') { + return $this->response->setJSON(['ok' => false, 'message' => '의견 내용을 입력해 주세요.']); + } + if (mb_strlen($content) > 1000) { + $content = mb_substr($content, 0, 1000); + } + + helper('admin'); + $lgIdx = function_exists('admin_effective_lg_idx') ? admin_effective_lg_idx() : null; + if (! $lgIdx) { + $raw = session()->get('mb_lg_idx'); + $lgIdx = ($raw !== null && $raw !== '') ? (int) $raw : null; + } + + $model = model(FeedbackModel::class); + $id = $model->insert([ + 'fb_lg_idx' => $lgIdx, + 'fb_mb_idx' => (int) session()->get('mb_idx'), + 'fb_mb_name' => (string) (session()->get('mb_name') ?? ''), + 'fb_mb_level' => (int) session()->get('mb_level'), + 'fb_content' => $content, + 'fb_page_url' => mb_substr((string) $this->request->getPost('page_url'), 0, 255), + 'fb_page_title' => mb_substr((string) $this->request->getPost('page_title'), 0, 255), + 'fb_user_agent' => mb_substr((string) $this->request->getHeaderLine('User-Agent'), 0, 255), + 'fb_status' => 'new', + ], true); + + if (! $id) { + return $this->response->setJSON(['ok' => false, 'message' => '저장 중 오류가 발생했습니다.']); + } + + $dir = WRITEPATH . 'uploads/feedback/'; + if (! is_dir($dir)) { + @mkdir($dir, 0775, true); + } + $fileModel = model(FeedbackFileModel::class); + + // 1) 화면 캡처(dataURL) — 실패해도 의견 본문은 이미 저장됨 + $shot = (string) $this->request->getPost('screenshot'); + if ($shot !== '' && preg_match('#^data:image/(png|jpeg);base64,#', $shot, $m) === 1) { + $ext = $m[1] === 'png' ? 'png' : 'jpg'; + $bin = base64_decode(substr($shot, strpos($shot, ',') + 1), true); + if ($bin !== false && strlen($bin) <= 5 * 1024 * 1024) { + $file = 'fb_' . (int) $id . '_cap_' . bin2hex(random_bytes(4)) . '.' . $ext; + if (@file_put_contents($dir . $file, $bin) !== false) { + $model->update($id, ['fb_screenshot' => $file]); // 목록 캡처여부 표시용 + $fileModel->insert(['ff_fb_idx' => $id, 'ff_kind' => 'capture', 'ff_path' => $file, 'ff_mime' => 'image/' . ($ext === 'png' ? 'png' : 'jpeg')]); + } + } + } + + // 2) 사용자가 첨부한 이미지(여러 장) + $allowed = ['image/png' => 'png', 'image/jpeg' => 'jpg', 'image/gif' => 'gif', 'image/webp' => 'webp']; + $count = 0; + foreach ($this->request->getFileMultiple('files') ?? [] as $f) { + if ($count >= 5 || $f === null || ! $f->isValid() || $f->hasMoved()) { + continue; + } + $mime = (string) $f->getMimeType(); + if (! isset($allowed[$mime]) || $f->getSize() > 5 * 1024 * 1024) { + continue; + } + $file = 'fb_' . (int) $id . '_up_' . bin2hex(random_bytes(4)) . '.' . $allowed[$mime]; + try { + $f->move($dir, $file); + $fileModel->insert(['ff_fb_idx' => $id, 'ff_kind' => 'upload', 'ff_path' => $file, 'ff_mime' => $mime]); + $count++; + } catch (\Throwable $e) { + // 개별 파일 실패는 건너뜀 + } + } + + return $this->response->setJSON(['ok' => true]); + } +} diff --git a/app/Models/FeedbackFileModel.php b/app/Models/FeedbackFileModel.php new file mode 100644 index 0000000..b7b9215 --- /dev/null +++ b/app/Models/FeedbackFileModel.php @@ -0,0 +1,23 @@ + '접수', + 'in_progress' => '처리중', + 'done' => '완료', + 'hold' => '보류', + ][$s] ?? $s; + } +} diff --git a/app/Views/admin/feedback/index.php b/app/Views/admin/feedback/index.php new file mode 100644 index 0000000..c6023cd --- /dev/null +++ b/app/Views/admin/feedback/index.php @@ -0,0 +1,66 @@ + $list + * @var string $status + */ +use App\Models\FeedbackModel; + +$list = $list ?? []; +$status = (string) ($status ?? ''); +$badge = static function (string $s): string { + $map = [ + 'new' => 'bg-blue-50 text-blue-700', + 'in_progress' => 'bg-amber-50 text-amber-700', + 'done' => 'bg-emerald-50 text-emerald-700', + 'hold' => 'bg-gray-100 text-gray-500', + ]; + $c = $map[$s] ?? 'bg-gray-100 text-gray-600'; + return '' . esc(FeedbackModel::statusLabel($s)) . ''; +}; +?> +
+ 사용자 의견 +
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
번호일시상태화면내용작성자캡처
접수된 의견이 없습니다.
fb_idx ?>fb_regdate ?? '')) ?>fb_status ?? 'new')) ?>fb_page_title ?: $row->fb_page_url)) ?>fb_content, 0, 50)) ?>fb_content) > 50 ? '…' : '' ?>fb_mb_name ?? '')) ?>fb_screenshot) ? '' : '' ?>
+
+
links() ?>
diff --git a/app/Views/admin/feedback/show.php b/app/Views/admin/feedback/show.php new file mode 100644 index 0000000..de1cb37 --- /dev/null +++ b/app/Views/admin/feedback/show.php @@ -0,0 +1,64 @@ + +
+ 의견 상세 #fb_idx ?> + 목록으로 +
+ +
+
+
의견 내용
+
+

fb_content) ?>

+
+
+
화면
fb_page_title ?: '-')) ?>
fb_page_url ?: '')) ?>
+
작성자
fb_mb_name ?? '')) ?> (idx fb_mb_idx ?>, level fb_mb_level ?>)
+
지자체
fb_lg_idx ?? 0) ?>
+
일시
fb_regdate ?? '')) ?>
+
브라우저
fb_user_agent ?? '')) ?>
+
+
+
+ +
+
처리
+
+ +
+ + +
+
+ + +
+ +
+
+
+ + + +
+
첨부 이미지 ()
+
+ +
+ + 첨부 이미지 + +
ff_kind === 'capture' ? '화면 캡처' : '사용자 첨부' ?>
+
+ +
+
+ diff --git a/app/Views/admin/layout.php b/app/Views/admin/layout.php index e9f5bcb..23b447c 100644 --- a/app/Views/admin/layout.php +++ b/app/Views/admin/layout.php @@ -190,5 +190,6 @@ tailwind.config = { window.addEventListener('storage', function (e) { if (e.key === FONT_KEY) applyScale(curScale()); }); })(); + diff --git a/app/Views/bag/layout/embed.php b/app/Views/bag/layout/embed.php index 7a9ab30..ca378fc 100644 --- a/app/Views/bag/layout/embed.php +++ b/app/Views/bag/layout/embed.php @@ -218,5 +218,6 @@ tailwind.config = { if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run, { once: true }); else run(); })(); + diff --git a/app/Views/bag/layout/portal.php b/app/Views/bag/layout/portal.php index cb925ee..52854be 100644 --- a/app/Views/bag/layout/portal.php +++ b/app/Views/bag/layout/portal.php @@ -285,5 +285,6 @@ tailwind.config = { if (minus) minus.addEventListener('click', function () { applyScale(curScale() - 10); }); })(); + diff --git a/app/Views/components/feedback_widget.php b/app/Views/components/feedback_widget.php new file mode 100644 index 0000000..4497b55 --- /dev/null +++ b/app/Views/components/feedback_widget.php @@ -0,0 +1,144 @@ + 로 포함. + */ +$apiPath = (string) parse_url(base_url('feedback'), PHP_URL_PATH); +?> +
+ +
+ + + + diff --git a/writable/database/feedback_file_table.sql b/writable/database/feedback_file_table.sql new file mode 100644 index 0000000..031c62d --- /dev/null +++ b/writable/database/feedback_file_table.sql @@ -0,0 +1,10 @@ +-- 사용자 의견 첨부 이미지(여러 장). 캡처/업로드 공통. +CREATE TABLE IF NOT EXISTS feedback_file ( + ff_idx INT AUTO_INCREMENT PRIMARY KEY, + ff_fb_idx INT NOT NULL, + ff_kind VARCHAR(20) NOT NULL DEFAULT 'upload', -- capture | upload + ff_path VARCHAR(255) NOT NULL, + ff_mime VARCHAR(50) NULL, + ff_regdate DATETIME NULL, + INDEX idx_ff_fb (ff_fb_idx) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; diff --git a/writable/database/feedback_table.sql b/writable/database/feedback_table.sql new file mode 100644 index 0000000..b9b0b49 --- /dev/null +++ b/writable/database/feedback_table.sql @@ -0,0 +1,21 @@ +-- 사용자 의견(피드백) 테이블 +-- 각 화면에서 사용자가 남긴 의견 + 자동수집 맥락(화면·계정·지자체) + 스크린샷 경로 저장. +CREATE TABLE IF NOT EXISTS feedback ( + fb_idx INT AUTO_INCREMENT PRIMARY KEY, + fb_lg_idx INT NULL, + fb_mb_idx INT NULL, + fb_mb_name VARCHAR(100) NULL, + fb_mb_level INT NULL, + fb_content TEXT NOT NULL, + fb_page_url VARCHAR(255) NULL, + fb_page_title VARCHAR(255) NULL, + fb_screenshot VARCHAR(255) NULL, + fb_user_agent VARCHAR(255) NULL, + fb_status VARCHAR(20) NOT NULL DEFAULT 'new', + fb_admin_memo TEXT NULL, + fb_regdate DATETIME NULL, + fb_updated DATETIME NULL, + INDEX idx_fb_lg (fb_lg_idx), + INDEX idx_fb_status (fb_status), + INDEX idx_fb_regdate (fb_regdate) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;