requireAuth(); if ($auth instanceof ResponseInterface) { return $auth; } try { $page = (int) ($this->request->getGet('page') ?? 1); $perPage = (int) ($this->request->getGet('per_page') ?? 10); if ($page < 1) $page = 1; if ($perPage < 1) $perPage = 10; $offset = ($page - 1) * $perPage; $searchField = trim((string) $this->request->getGet('search_field')); // '', field, area, name $search = trim((string) $this->request->getGet('search')); $partnership = trim((string) $this->request->getGet('partnership')); $status = trim((string) $this->request->getGet('status')); $startDate = trim((string) $this->request->getGet('start_date')); // YYYY-MM-DD $endDate = trim((string) $this->request->getGet('end_date')); // YYYY-MM-DD $db = $this->getDB(); $builder = $db->table($this->table . ' o'); $builder->join('fishing_field f', 'f.id = o.field_id', 'left'); $builder->join('fishing_area a', 'a.id = o.area_id', 'left'); $builder->where('o.deleted_YN', 'N'); if ($search !== '') { if ($searchField === 'field') { $builder->like('f.name', $search); } elseif ($searchField === 'area') { $builder->like('a.name', $search); } elseif ($searchField === 'name') { $builder->like('o.name', $search); } else { // 전체: 분야 / 지역명 / 선상명 $builder->groupStart() ->like('f.name', $search) ->orLike('a.name', $search) ->orLike('o.name', $search) ->groupEnd(); } } if ($partnership === 'Y' || $partnership === 'N') { $builder->where('o.partnership_YN', $partnership); } if ($status === 'Y' || $status === 'N') { $builder->where('o.status_YN', $status); } // 등록일 기간 필터 (YYYY-MM-DD) if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate)) { $builder->where('o.created_at >=', $startDate . ' 00:00:00'); } if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate)) { $builder->where('o.created_at <=', $endDate . ' 23:59:59'); } $total = $builder->countAllResults(false); // 계좌번호는 목록에서 제외 (민감정보) $items = $builder ->select('o.id, o.name, o.field_id, o.area_id, o.area_detail, o.partnership_YN, o.status_YN, o.created_at, f.name as field_name, a.name as area_name') ->orderBy('o.id', 'DESC') ->limit($perPage, $offset) ->get() ->getResult(); return $this->respondSuccess([ 'items' => $items, 'total' => $total, 'page' => $page, 'per_page' => $perPage, 'total_pages' => (int) ceil($total / $perPage), ]); } catch (\Exception $e) { log_message('error', 'OnboardController index error: ' . $e->getMessage()); return $this->respondError('목록 조회 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR); } } /** * 선상 등록 * POST /api/onboard */ public function create() { $auth = $this->requireAuth(); if ($auth instanceof ResponseInterface) { return $auth; } try { $payload = $this->request->getJSON(true); if (!is_array($payload) || empty($payload)) { $payload = $this->request->getPost() ?? []; } $fieldId = (int) ($payload['field_id'] ?? 0); $areaId = (int) ($payload['area_id'] ?? 0); $name = trim((string) ($payload['name'] ?? '')); // 필수값 검증 if ($fieldId <= 0) { return $this->respondError('분야를 선택하세요.', ResponseInterface::HTTP_BAD_REQUEST); } if ($areaId <= 0) { return $this->respondError('지역을 선택하세요.', ResponseInterface::HTTP_BAD_REQUEST); } if ($name === '') { return $this->respondError('선상명을 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST); } if (mb_strlen($name) > 100) { return $this->respondError('선상명은 100자 이내로 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST); } $db = $this->getDB(); // 분야 / 지역 존재 확인 $fieldExists = $db->table('fishing_field') ->where('id', $fieldId)->where('deleted_YN', 'N')->countAllResults(); if ($fieldExists === 0) { return $this->respondError('존재하지 않는 분야입니다.', ResponseInterface::HTTP_BAD_REQUEST); } $areaExists = $db->table('fishing_area') ->where('id', $areaId)->where('deleted_YN', 'N')->countAllResults(); if ($areaExists === 0) { return $this->respondError('존재하지 않는 지역입니다.', ResponseInterface::HTTP_BAD_REQUEST); } // Y/N 정규화 $partnership = (($payload['partnership_YN'] ?? 'N') === 'Y') ? 'Y' : 'N'; $status = (($payload['status_YN'] ?? 'Y') === 'N') ? 'N' : 'Y'; $insertData = [ 'field_id' => $fieldId, 'area_id' => $areaId, 'name' => $name, 'area_detail' => trim((string) ($payload['area_detail'] ?? '')), 'tonnage' => trim((string) ($payload['tonnage'] ?? '')), 'capacity' => trim((string) ($payload['capacity'] ?? '')), 'zip_code' => trim((string) ($payload['zip_code'] ?? '')), 'address' => trim((string) ($payload['address'] ?? '')), 'address_detail' => trim((string) ($payload['address_detail'] ?? '')), 'address_refer' => trim((string) ($payload['address_refer'] ?? '')), 'lat' => trim((string) ($payload['lat'] ?? '')), 'lng' => trim((string) ($payload['lng'] ?? '')), 'partnership_YN' => $partnership, 'status_YN' => $status, 'created_at' => date('Y-m-d H:i:s'), ]; // 제휴인 경우에만 계좌 정보 저장 (비제휴면 빈 값) // 계좌번호는 양방향 암호화하여 저장 if ($partnership === 'Y') { $insertData['bank_code'] = trim((string) ($payload['bank_code'] ?? '')); $insertData['account_number'] = $this->encryptValue(trim((string) ($payload['account_number'] ?? ''))); $insertData['account_holder'] = trim((string) ($payload['account_holder'] ?? '')); } else { $insertData['bank_code'] = ''; $insertData['account_number'] = ''; $insertData['account_holder'] = ''; } if (!$db->table($this->table)->insert($insertData)) { return $this->respondError('등록에 실패했습니다.', ResponseInterface::HTTP_INTERNAL_SERVER_ERROR); } $newId = $db->insertID(); $row = $db->table($this->table)->where('id', $newId)->get()->getRow(); // 응답 시 계좌번호 복호화 if ($row) { $row->account_number = $this->decryptValue($row->account_number); } return $this->respondSuccess($row, '선상이 등록되었습니다.', ResponseInterface::HTTP_CREATED); } catch (\Exception $e) { log_message('error', 'OnboardController create error: ' . $e->getMessage()); return $this->respondError('등록 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR); } } /** * 선상 상세 조회 * GET /api/onboard/:id */ public function show($id = null) { $auth = $this->requireAuth(); if ($auth instanceof ResponseInterface) { return $auth; } if (empty($id)) { return $this->respondError('ID가 필요합니다.', ResponseInterface::HTTP_BAD_REQUEST); } try { $row = $this->getDB()->table($this->table . ' o') ->select('o.*, f.name as field_name, a.name as area_name') ->join('fishing_field f', 'f.id = o.field_id', 'left') ->join('fishing_area a', 'a.id = o.area_id', 'left') ->where('o.id', (int) $id) ->where('o.deleted_YN', 'N') ->get() ->getRow(); if (!$row) { return $this->respondError('해당 선상을 찾을 수 없습니다.', ResponseInterface::HTTP_NOT_FOUND); } // 계좌번호 복호화 $row->account_number = $this->decryptValue($row->account_number); // 사진 목록 (정렬순) $row->photos = $this->getDB()->table('onboard_photos') ->where('onboard_id', (int) $id) ->orderBy('sort_order', 'ASC') ->orderBy('id', 'ASC') ->get() ->getResult(); return $this->respondSuccess($row); } catch (\Exception $e) { log_message('error', 'OnboardController show error: ' . $e->getMessage()); return $this->respondError('조회 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR); } } /** * 선상 수정 * PUT /api/onboard/:id */ public function update($id = null) { $auth = $this->requireAuth(); if ($auth instanceof ResponseInterface) { return $auth; } if (empty($id)) { return $this->respondError('ID가 필요합니다.', ResponseInterface::HTTP_BAD_REQUEST); } try { $payload = $this->request->getJSON(true); if (!is_array($payload) || empty($payload)) { $payload = $this->request->getRawInput() ?? []; } $fieldId = (int) ($payload['field_id'] ?? 0); $areaId = (int) ($payload['area_id'] ?? 0); $name = trim((string) ($payload['name'] ?? '')); if ($fieldId <= 0) { return $this->respondError('분야를 선택하세요.', ResponseInterface::HTTP_BAD_REQUEST); } if ($areaId <= 0) { return $this->respondError('지역을 선택하세요.', ResponseInterface::HTTP_BAD_REQUEST); } if ($name === '') { return $this->respondError('선상명을 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST); } if (mb_strlen($name) > 100) { return $this->respondError('선상명은 100자 이내로 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST); } $db = $this->getDB(); // 대상 존재 확인 $exists = $db->table($this->table) ->where('id', (int) $id)->where('deleted_YN', 'N')->countAllResults(); if ($exists === 0) { return $this->respondError('해당 선상을 찾을 수 없습니다.', ResponseInterface::HTTP_NOT_FOUND); } // 분야 / 지역 존재 확인 $fieldExists = $db->table('fishing_field') ->where('id', $fieldId)->where('deleted_YN', 'N')->countAllResults(); if ($fieldExists === 0) { return $this->respondError('존재하지 않는 분야입니다.', ResponseInterface::HTTP_BAD_REQUEST); } $areaExists = $db->table('fishing_area') ->where('id', $areaId)->where('deleted_YN', 'N')->countAllResults(); if ($areaExists === 0) { return $this->respondError('존재하지 않는 지역입니다.', ResponseInterface::HTTP_BAD_REQUEST); } $partnership = (($payload['partnership_YN'] ?? 'N') === 'Y') ? 'Y' : 'N'; $status = (($payload['status_YN'] ?? 'Y') === 'N') ? 'N' : 'Y'; $updateData = [ 'field_id' => $fieldId, 'area_id' => $areaId, 'name' => $name, 'area_detail' => trim((string) ($payload['area_detail'] ?? '')), 'tonnage' => trim((string) ($payload['tonnage'] ?? '')), 'capacity' => trim((string) ($payload['capacity'] ?? '')), 'zip_code' => trim((string) ($payload['zip_code'] ?? '')), 'address' => trim((string) ($payload['address'] ?? '')), 'address_detail' => trim((string) ($payload['address_detail'] ?? '')), 'address_refer' => trim((string) ($payload['address_refer'] ?? '')), 'lat' => trim((string) ($payload['lat'] ?? '')), 'lng' => trim((string) ($payload['lng'] ?? '')), 'partnership_YN' => $partnership, 'status_YN' => $status, 'updated_at' => date('Y-m-d H:i:s'), ]; // 제휴면 계좌 정보 (계좌번호 재암호화), 비제휴면 빈 값 if ($partnership === 'Y') { $updateData['bank_code'] = trim((string) ($payload['bank_code'] ?? '')); $updateData['account_number'] = $this->encryptValue(trim((string) ($payload['account_number'] ?? ''))); $updateData['account_holder'] = trim((string) ($payload['account_holder'] ?? '')); } else { $updateData['bank_code'] = ''; $updateData['account_number'] = ''; $updateData['account_holder'] = ''; } $db->table($this->table)->where('id', (int) $id)->update($updateData); $row = $db->table($this->table)->where('id', (int) $id)->get()->getRow(); if ($row) { $row->account_number = $this->decryptValue($row->account_number); } return $this->respondSuccess($row, '선상이 수정되었습니다.'); } catch (\Exception $e) { log_message('error', 'OnboardController update error: ' . $e->getMessage()); return $this->respondError('수정 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR); } } /** * 선상 사진 삭제 (파일 + DB hard delete) * DELETE /api/onboard/photo/:photoId */ public function deletePhoto($photoId = null) { $auth = $this->requireAuth(); if ($auth instanceof ResponseInterface) { return $auth; } if (empty($photoId)) { return $this->respondError('사진 ID가 필요합니다.', ResponseInterface::HTTP_BAD_REQUEST); } try { $db = $this->getDB(); $photo = $db->table('onboard_photos')->where('id', (int) $photoId)->get()->getRow(); if (!$photo) { return $this->respondError('해당 사진을 찾을 수 없습니다.', ResponseInterface::HTTP_NOT_FOUND); } // 실제 파일 삭제 $fullPath = FCPATH . ltrim($photo->file_path, '/'); if (is_file($fullPath)) { @unlink($fullPath); } $db->table('onboard_photos')->where('id', (int) $photoId)->delete(); return $this->respondSuccess(null, '사진이 삭제되었습니다.'); } catch (\Exception $e) { log_message('error', 'OnboardController deletePhoto error: ' . $e->getMessage()); return $this->respondError('사진 삭제 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR); } } /** * 선상 사진 업로드 (다중) * POST /api/onboard/:id/photos (multipart, photos[]) */ public function uploadPhotos($id = null) { $auth = $this->requireAuth(); if ($auth instanceof ResponseInterface) { return $auth; } if (empty($id)) { return $this->respondError('선상 ID가 필요합니다.', ResponseInterface::HTTP_BAD_REQUEST); } try { $db = $this->getDB(); // 선상 존재 확인 $exists = $db->table($this->table) ->where('id', (int) $id)->where('deleted_YN', 'N')->countAllResults(); if ($exists === 0) { return $this->respondError('해당 선상을 찾을 수 없습니다.', ResponseInterface::HTTP_NOT_FOUND); } $files = $this->request->getFileMultiple('photos'); if (empty($files)) { return $this->respondError('업로드할 사진이 없습니다.', ResponseInterface::HTTP_BAD_REQUEST); } $allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; $uploadPath = FCPATH . 'uploads/onboard/'; if (!is_dir($uploadPath)) { mkdir($uploadPath, 0755, true); } // 기존 사진의 최대 sort_order 다음부터 부여 $maxRow = $db->table('onboard_photos') ->selectMax('sort_order') ->where('onboard_id', (int) $id) ->get()->getRow(); $order = $maxRow && $maxRow->sort_order !== null ? (int) $maxRow->sort_order + 1 : 0; $saved = []; foreach ($files as $file) { if (!$file->isValid()) { continue; } // 실제 파일 기반 MIME 검증 $mime = $file->getMimeType(); if (!in_array($mime, $allowed, true)) { continue; } $originalName = $file->getClientName(); $size = $file->getSize(); $newName = $file->getRandomName(); $file->move($uploadPath, $newName); $fullPath = $uploadPath . $newName; // 이미지 크기 추출 $width = null; $height = null; $info = @getimagesize($fullPath); if ($info) { $width = $info[0]; $height = $info[1]; } $photoData = [ 'onboard_id' => (int) $id, 'original_name' => $originalName, 'stored_name' => $newName, 'file_path' => '/uploads/onboard/' . $newName, 'file_size' => $size, 'mime_type' => $mime, 'width' => $width, 'height' => $height, 'sort_order' => $order, 'created_at' => date('Y-m-d H:i:s'), ]; $db->table('onboard_photos')->insert($photoData); $photoData['id'] = $db->insertID(); $saved[] = $photoData; $order++; } if (empty($saved)) { return $this->respondError('유효한 이미지 파일이 없습니다. (JPG/PNG/GIF/WebP만 허용)', ResponseInterface::HTTP_BAD_REQUEST); } return $this->respondSuccess($saved, count($saved) . '장의 사진이 업로드되었습니다.', ResponseInterface::HTTP_CREATED); } catch (\Exception $e) { log_message('error', 'OnboardController uploadPhotos error: ' . $e->getMessage()); return $this->respondError('사진 업로드 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR); } } /** * 선상 삭제 (soft delete) * DELETE /api/onboard/:id */ public function delete($id = null) { $auth = $this->requireAuth(); if ($auth instanceof ResponseInterface) { return $auth; } if (empty($id)) { return $this->respondError('ID가 필요합니다.', ResponseInterface::HTTP_BAD_REQUEST); } try { $db = $this->getDB(); $exists = $db->table($this->table) ->where('id', (int) $id) ->where('deleted_YN', 'N') ->countAllResults(); if ($exists === 0) { return $this->respondError('해당 선상을 찾을 수 없습니다.', ResponseInterface::HTTP_NOT_FOUND); } $db->table($this->table) ->where('id', (int) $id) ->update([ 'deleted_YN' => 'Y', 'updated_at' => date('Y-m-d H:i:s'), ]); return $this->respondSuccess(null, '선상이 삭제되었습니다.'); } catch (\Exception $e) { log_message('error', 'OnboardController delete error: ' . $e->getMessage()); return $this->respondError('삭제 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR); } } /** * 값 암호화 (빈 값은 그대로 빈 문자열) */ private function encryptValue(string $plain): string { if ($plain === '') { return ''; } $encrypter = \Config\Services::encrypter(); return base64_encode($encrypter->encrypt($plain)); } /** * 값 복호화 (실패/빈 값이면 빈 문자열) */ private function decryptValue(?string $cipher): string { if (empty($cipher)) { return ''; } try { $encrypter = \Config\Services::encrypter(); return $encrypter->decrypt(base64_decode($cipher)); } catch (\Exception $e) { log_message('error', 'Account decrypt error: ' . $e->getMessage()); return ''; } } }