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)) . '';
+};
+?>
+
| 번호 | +일시 | +상태 | +화면 | +내용 | +작성자 | +캡처 | +
|---|---|---|---|---|---|---|
| 접수된 의견이 없습니다. | ||||||
| = (int) $row->fb_idx ?> | += esc((string) ($row->fb_regdate ?? '')) ?> | += $badge((string) ($row->fb_status ?? 'new')) ?> | += esc((string) ($row->fb_page_title ?: $row->fb_page_url)) ?> | += esc(mb_substr((string) $row->fb_content, 0, 50)) ?>= mb_strlen((string) $row->fb_content) > 50 ? '…' : '' ?> | += esc((string) ($row->fb_mb_name ?? '')) ?> | += ! empty($row->fb_screenshot) ? '' : '' ?> | +
= esc((string) $row->fb_content) ?>
+