feat: 사용자 의견(피드백) 기능 — 화면 캡처+이미지 첨부, 관리자 검토
- 전 화면 우하단 '의견' 위젯(embed/portal/admin 레이아웃) — 로그인 사용자 - 의견 + html2canvas 화면 캡처 + 이미지 첨부(최대 5장) 저장 - 화면 URL/계정/지자체 자동 수집(계정·지자체는 서버 세션 기준) - 관리자 /admin/feedback 목록·상세·상태(접수/처리중/완료/보류)·메모, 테넌트 분리 - feedback/feedback_file 테이블, 첨부 이미지는 관리자 전용 라우트로 서빙 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
145
app/Controllers/Admin/Feedback.php
Normal file
145
app/Controllers/Admin/Feedback.php
Normal file
@@ -0,0 +1,145 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers\Admin;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\FeedbackFileModel;
|
||||
use App\Models\FeedbackModel;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
use Config\Roles;
|
||||
|
||||
/**
|
||||
* 사용자 의견 검토(관리자). super·본부는 전체, 지자체관리자는 자기 지자체만.
|
||||
*/
|
||||
class Feedback extends BaseController
|
||||
{
|
||||
private function scopeLgIdx(): ?int
|
||||
{
|
||||
helper('admin');
|
||||
$level = (int) session()->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));
|
||||
}
|
||||
}
|
||||
98
app/Controllers/Feedback.php
Normal file
98
app/Controllers/Feedback.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Controllers;
|
||||
|
||||
use App\Models\FeedbackFileModel;
|
||||
use App\Models\FeedbackModel;
|
||||
use CodeIgniter\HTTP\ResponseInterface;
|
||||
|
||||
/**
|
||||
* 사용자 의견 접수. 로그인 사용자가 화면에서 남긴 의견 + 스크린샷을 저장한다.
|
||||
* (계정·지자체는 클라이언트 값을 믿지 않고 세션에서 채운다.)
|
||||
*/
|
||||
class Feedback extends BaseController
|
||||
{
|
||||
public function store(): ResponseInterface
|
||||
{
|
||||
if (! session()->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]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user