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; $search = trim((string) $this->request->getGet('search')); $status = trim((string) $this->request->getGet('status')); // recruiting/running/ended $statusYN = trim((string) $this->request->getGet('status_YN')); // Y/N $startDate = trim((string) $this->request->getGet('start_date')); $endDate = trim((string) $this->request->getGet('end_date')); $db = $this->getDB(); $builder = $db->table($this->table); $builder->where('deleted_YN', 'N'); if ($search !== '') { $builder->like('name', $search); } if ($statusYN === 'Y' || $statusYN === 'N') { $builder->where('status_YN', $statusYN); } // 상태 필터 (비노출 > 명시 마감 > end_date 경과 > 모집중 > 진행중 순위) $now = date('Y-m-d H:i:s'); if ($status === 'hidden') { $builder->where('status_YN', 'N'); } elseif ($status === 'ended') { $builder->where('status_YN', 'Y') ->groupStart() ->where('closed_at IS NOT NULL') ->orWhere('end_date <', $now) ->groupEnd(); } elseif ($status === 'recruiting') { $builder->where('status_YN', 'Y') ->where('closed_at IS NULL') ->where('end_date >=', $now) ->where('start_date >', $now); } elseif ($status === 'running') { $builder->where('status_YN', 'Y') ->where('closed_at IS NULL') ->where('end_date >=', $now) ->where('start_date <=', $now); } // 등록일 기간 필터 if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate)) { $builder->where('created_at >=', $startDate . ' 00:00:00'); } if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate)) { $builder->where('created_at <=', $endDate . ' 23:59:59'); } $total = $builder->countAllResults(false); // 상태/현재라운드는 SQL CASE+subquery로 계산 (escape 비활성화) $items = $builder ->select(" challenge.id, challenge.name, challenge.fee, challenge.start_date, challenge.end_date, challenge.max_participants, challenge.total_rounds, challenge.file_name, challenge.file_path, challenge.closed_at, challenge.closed_by, challenge.status_YN, challenge.created_at, CASE WHEN challenge.status_YN = 'N' THEN 'hidden' WHEN challenge.closed_at IS NOT NULL THEN 'ended' WHEN challenge.end_date < NOW() THEN 'ended' WHEN challenge.start_date > NOW() THEN 'recruiting' ELSE 'running' END AS derived_status, CASE WHEN challenge.closed_at IS NOT NULL THEN challenge.total_rounds WHEN challenge.end_date < NOW() THEN challenge.total_rounds ELSE COALESCE( (SELECT MIN(cr.round_no) FROM challenge_round cr WHERE cr.challenge_id = challenge.id AND cr.closed_at IS NULL), challenge.total_rounds ) END AS current_round ", false) ->orderBy('challenge.id', 'DESC') ->limit($perPage, $offset) ->get() ->getResult(); // 상태별 카운트 (필터 무관 — 전체 기준, 우선순위 derived_status와 동일) $countAll = $db->table($this->table)->where('deleted_YN', 'N')->countAllResults(); $countHidden = $db->table($this->table) ->where('deleted_YN', 'N') ->where('status_YN', 'N') ->countAllResults(); $countRecruiting = $db->table($this->table) ->where('deleted_YN', 'N') ->where('status_YN', 'Y') ->where('closed_at IS NULL') ->where('end_date >=', $now) ->where('start_date >', $now) ->countAllResults(); $countRunning = $db->table($this->table) ->where('deleted_YN', 'N') ->where('status_YN', 'Y') ->where('closed_at IS NULL') ->where('end_date >=', $now) ->where('start_date <=', $now) ->countAllResults(); $countEnded = $db->table($this->table) ->where('deleted_YN', 'N') ->where('status_YN', 'Y') ->groupStart() ->where('closed_at IS NOT NULL') ->orWhere('end_date <', $now) ->groupEnd() ->countAllResults(); return $this->respondSuccess([ 'items' => $items, 'total' => $total, 'page' => $page, 'per_page' => $perPage, 'total_pages' => (int) ceil($total / $perPage), 'counts' => [ 'all' => $countAll, 'recruiting' => $countRecruiting, 'running' => $countRunning, 'ended' => $countEnded, 'hidden' => $countHidden, ], ]); } catch (\Exception $e) { log_message('error', 'ChallengeController index error: ' . $e->getMessage()); return $this->respondError('목록 조회 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR); } } /** * 챌린지 상세 조회 * GET /api/challenge/: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 { $db = $this->getDB(); // 1. 챌린지 기본 정보 + 상태 계산 (CASE 표현식 때문에 escape 비활성화) // detail은 시간 기반 상태만 — 노출 여부는 별도로 표시되므로 hidden 케이스 제외 $row = $db->table($this->table) ->select(" id, name, fee, start_date, end_date, max_participants, total_rounds, description, file_name, file_path, closed_at, closed_by, status_YN, deleted_YN, created_at, updated_at, CASE WHEN closed_at IS NOT NULL THEN 'ended' WHEN end_date < NOW() THEN 'ended' WHEN start_date > NOW() THEN 'recruiting' ELSE 'running' END AS derived_status ", false) ->where('id', (int) $id) ->where('deleted_YN', 'N') ->get() ->getRow(); if (!$row) { return $this->respondError('해당 챌린지를 찾을 수 없습니다.', ResponseInterface::HTTP_NOT_FOUND); } // 2. 라운드 목록 $rounds = $db->table('challenge_round') ->where('challenge_id', (int) $id) ->orderBy('round_no', 'ASC') ->get() ->getResult(); foreach ($rounds as $r) { if ($r->place_mode === 'all') { // 라운드 단위 아이템 $r->items = $db->table('challenge_round_item cri') ->select('cri.id, cri.item_id, i.name, i.type, i.point, i.file_name, i.file_path') ->join('item i', 'i.id = cri.item_id', 'left') ->where('cri.round_id', $r->id) ->orderBy('cri.id', 'ASC') ->get() ->getResult(); $r->places = []; } else { // specific — group_no로 묶어서 묶음(place) 단위로 반환 $placeRows = $db->table('challenge_round_place crp') ->select("crp.id, crp.group_no, crp.place_type, crp.place_id, COALESCE(o.name, f.name) AS place_name", false) ->join('onboard o', "o.id = crp.place_id AND crp.place_type = 'onboard'", 'left') ->join('fishing f', "f.id = crp.place_id AND crp.place_type = 'fishing'", 'left') ->where('crp.round_id', $r->id) ->orderBy('crp.group_no', 'ASC') ->orderBy('crp.id', 'ASC') ->get() ->getResult(); $groups = []; // group_no => place 묶음 foreach ($placeRows as $pr) { $g = (int) $pr->group_no; if (!isset($groups[$g])) { // group 단위로 아이템 조회 (round_id + group_no) $items = $db->table('challenge_round_group_item cgi') ->select('cgi.id, cgi.item_id, i.name, i.type, i.point, i.file_name, i.file_path') ->join('item i', 'i.id = cgi.item_id', 'left') ->where('cgi.round_id', $r->id) ->where('cgi.group_no', $g) ->orderBy('cgi.id', 'ASC') ->get() ->getResult(); $groups[$g] = (object) [ 'group_no' => $g, 'onboards' => [], 'items' => $items, ]; } $groups[$g]->onboards[] = (object) [ 'id' => $pr->id, 'place_type' => $pr->place_type, 'place_id' => $pr->place_id, 'place_name' => $pr->place_name, ]; } $r->places = array_values($groups); $r->items = []; } } $row->rounds = $rounds; return $this->respondSuccess($row); } catch (\Exception $e) { log_message('error', 'ChallengeController show error: ' . $e->getMessage()); return $this->respondError('조회 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR); } } /** * 챌린지 삭제 (soft delete) * DELETE /api/challenge/: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', 'ChallengeController delete error: ' . $e->getMessage()); return $this->respondError('삭제 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR); } } /** * 챌린지 등록 * POST /api/challenge */ 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() ?? []; } $name = trim((string) ($payload['name'] ?? '')); $fee = trim((string) ($payload['fee'] ?? '')); $startDate = trim((string) ($payload['start_date'] ?? '')); $endDate = trim((string) ($payload['end_date'] ?? '')); $maxParticipants = (int) ($payload['max_participants'] ?? 0); $description = (string) ($payload['description'] ?? ''); $status = (($payload['status_YN'] ?? 'Y') === 'N') ? 'N' : 'Y'; $rounds = $payload['rounds'] ?? []; // ============================ // 기본 필수값 검증 // ============================ if ($name === '') { return $this->respondError('챌린지명을 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST); } if (mb_strlen($name) > 255) { return $this->respondError('챌린지명은 255자 이내로 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST); } if ($fee === '') { return $this->respondError('참가비를 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST); } if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate)) { return $this->respondError('시작일을 올바르게 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST); } if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate)) { return $this->respondError('종료일을 올바르게 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST); } if (strtotime($endDate) < strtotime($startDate)) { return $this->respondError('종료일은 시작일과 같거나 이후여야 합니다.', ResponseInterface::HTTP_BAD_REQUEST); } if ($maxParticipants < 100 || $maxParticipants > 999999) { return $this->respondError('최대 참가자 수는 100명 이상 999,999명 이하로 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST); } // ============================ // 라운드 검증 // ============================ if (!is_array($rounds) || count($rounds) < 2 || count($rounds) > 5) { return $this->respondError('라운드는 2~5개 사이여야 합니다.', ResponseInterface::HTTP_BAD_REQUEST); } foreach ($rounds as $idx => $r) { $no = $idx + 1; $placeMode = (string) ($r['place_mode'] ?? ''); $qualified = (int) ($r['qualified'] ?? 0); if (!in_array($placeMode, ['all', 'specific'], true)) { return $this->respondError("라운드 {$no}의 장소 모드를 선택하세요.", ResponseInterface::HTTP_BAD_REQUEST); } if ($qualified <= 0) { return $this->respondError("라운드 {$no}의 진출자 수를 입력하세요.", ResponseInterface::HTTP_BAD_REQUEST); } if ($placeMode === 'specific') { $places = $r['places'] ?? []; if (!is_array($places) || count($places) === 0) { return $this->respondError("라운드 {$no}에 장소를 1개 이상 추가하세요.", ResponseInterface::HTTP_BAD_REQUEST); } foreach ($places as $pIdx => $p) { $pNo = $pIdx + 1; $onboards = $p['onboards'] ?? []; if (!is_array($onboards) || count($onboards) === 0) { return $this->respondError("라운드 {$no} 장소 {$pNo}에 선상을 1개 이상 선택하세요.", ResponseInterface::HTTP_BAD_REQUEST); } } } } $db = $this->getDB(); $db->transStart(); // ============================ // 1. challenge INSERT // ============================ $challengeData = [ 'name' => $name, 'fee' => $fee, 'start_date' => $startDate . ' 00:00:00', 'end_date' => $endDate . ' 23:59:59', 'max_participants' => $maxParticipants, 'total_rounds' => count($rounds), 'description' => $description, 'status_YN' => $status, 'deleted_YN' => 'N', 'created_at' => date('Y-m-d H:i:s'), ]; $db->table('challenge')->insert($challengeData); $challengeId = $db->insertID(); if (!$challengeId) { $db->transRollback(); return $this->respondError('챌린지 등록에 실패했습니다.', ResponseInterface::HTTP_INTERNAL_SERVER_ERROR); } // ============================ // 2. 라운드 + 자식 테이블 INSERT // ============================ foreach ($rounds as $idx => $r) { $roundNo = $idx + 1; $placeMode = $r['place_mode']; $qualified = (int) $r['qualified']; $db->table('challenge_round')->insert([ 'challenge_id' => $challengeId, 'round_no' => $roundNo, 'place_mode' => $placeMode, 'qualified' => $qualified, ]); $roundId = $db->insertID(); if ($placeMode === 'all') { // 라운드 단위 아이템 (0개 허용) $items = $r['items'] ?? []; foreach ($items as $it) { $itemId = (int) ($it['item_id'] ?? 0); if ($itemId <= 0) continue; $db->table('challenge_round_item')->insert([ 'round_id' => $roundId, 'item_id' => $itemId, ]); } } else { // specific — 각 묶음(place)을 group_no로 식별 // 장소는 선상별로 분해, 아이템은 group 단위로 1번만 INSERT $places = $r['places'] ?? []; foreach ($places as $placeIdx => $p) { $groupNo = $placeIdx + 1; $onboards = $p['onboards'] ?? []; // [{type:'onboard'|'fishing', id:int}, ...] $placeItems = $p['items'] ?? []; // 1. 묶음의 각 장소 INSERT (challenge_round_place) foreach ($onboards as $sel) { $placeType = (string) ($sel['type'] ?? 'onboard'); $placeId = (int) ($sel['id'] ?? 0); if ($placeId <= 0) continue; if (!in_array($placeType, ['onboard', 'fishing'], true)) continue; $db->table('challenge_round_place')->insert([ 'round_id' => $roundId, 'group_no' => $groupNo, 'place_type' => $placeType, 'place_id' => $placeId, ]); } // 2. 묶음의 아이템 INSERT (challenge_round_group_item) — group 단위 1번만 foreach ($placeItems as $it) { $itemId = (int) ($it['item_id'] ?? 0); if ($itemId <= 0) continue; $db->table('challenge_round_group_item')->insert([ 'round_id' => $roundId, 'group_no' => $groupNo, 'item_id' => $itemId, ]); } } } } $db->transComplete(); if ($db->transStatus() === false) { return $this->respondError('등록 중 데이터베이스 오류가 발생했습니다.', ResponseInterface::HTTP_INTERNAL_SERVER_ERROR); } // 결과 응답 $row = $db->table('challenge')->where('id', $challengeId)->get()->getRow(); return $this->respondSuccess($row, '챌린지가 등록되었습니다.', ResponseInterface::HTTP_CREATED); } catch (\Exception $e) { log_message('error', 'ChallengeController create error: ' . $e->getMessage()); return $this->respondError('등록 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR); } } /** * 챌린지 수정 * PUT /api/challenge/:id * 주의: end_date는 등록 후 수정 불가 (정책) * 자식 데이터(라운드/장소/아이템)는 모두 DELETE 후 재구성 */ 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() ?? []; } $name = trim((string) ($payload['name'] ?? '')); $fee = trim((string) ($payload['fee'] ?? '')); $startDate = trim((string) ($payload['start_date'] ?? '')); $endDate = trim((string) ($payload['end_date'] ?? '')); $maxParticipants = (int) ($payload['max_participants'] ?? 0); $description = (string) ($payload['description'] ?? ''); $status = (($payload['status_YN'] ?? 'Y') === 'N') ? 'N' : 'Y'; $rounds = $payload['rounds'] ?? []; // 필수값 검증 if ($name === '') return $this->respondError('챌린지명을 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST); if (mb_strlen($name) > 255) return $this->respondError('챌린지명은 255자 이내로 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST); if ($fee === '') return $this->respondError('참가비를 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST); if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate)) { return $this->respondError('시작일을 올바르게 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST); } if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate)) { return $this->respondError('종료일을 올바르게 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST); } if (strtotime($endDate) < strtotime($startDate)) { return $this->respondError('종료일은 시작일과 같거나 이후여야 합니다.', ResponseInterface::HTTP_BAD_REQUEST); } if ($maxParticipants < 100 || $maxParticipants > 999999) { return $this->respondError('최대 참가자 수는 100명 이상 999,999명 이하로 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST); } // 라운드 검증 if (!is_array($rounds) || count($rounds) < 2 || count($rounds) > 5) { return $this->respondError('라운드는 2~5개 사이여야 합니다.', ResponseInterface::HTTP_BAD_REQUEST); } foreach ($rounds as $idx => $r) { $no = $idx + 1; $placeMode = (string) ($r['place_mode'] ?? ''); $qualified = (int) ($r['qualified'] ?? 0); if (!in_array($placeMode, ['all', 'specific'], true)) { return $this->respondError("라운드 {$no}의 장소 모드를 선택하세요.", ResponseInterface::HTTP_BAD_REQUEST); } if ($qualified <= 0) { return $this->respondError("라운드 {$no}의 진출자 수를 입력하세요.", ResponseInterface::HTTP_BAD_REQUEST); } if ($placeMode === 'specific') { $places = $r['places'] ?? []; if (!is_array($places) || count($places) === 0) { return $this->respondError("라운드 {$no}에 장소를 1개 이상 추가하세요.", ResponseInterface::HTTP_BAD_REQUEST); } foreach ($places as $pIdx => $p) { $pNo = $pIdx + 1; $onboards = $p['onboards'] ?? []; if (!is_array($onboards) || count($onboards) === 0) { return $this->respondError("라운드 {$no} 장소 {$pNo}에 선상/낚시터를 1개 이상 선택하세요.", 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); } $db->transStart(); // 1. challenge UPDATE $db->table('challenge') ->where('id', (int) $id) ->update([ 'name' => $name, 'fee' => $fee, 'start_date' => $startDate . ' 00:00:00', 'end_date' => $endDate . ' 23:59:59', 'max_participants' => $maxParticipants, 'total_rounds' => count($rounds), 'description' => $description, 'status_YN' => $status, 'updated_at' => date('Y-m-d H:i:s'), ]); // 2. 기존 자식 데이터 모두 DELETE (CASCADE로 round_item, round_place, group_item 다 정리됨) $db->table('challenge_round')->where('challenge_id', (int) $id)->delete(); // 3. 라운드 + 자식 INSERT (create 와 동일 로직) foreach ($rounds as $idx => $r) { $roundNo = $idx + 1; $placeMode = $r['place_mode']; $qualified = (int) $r['qualified']; $db->table('challenge_round')->insert([ 'challenge_id' => (int) $id, 'round_no' => $roundNo, 'place_mode' => $placeMode, 'qualified' => $qualified, ]); $roundId = $db->insertID(); if ($placeMode === 'all') { $items = $r['items'] ?? []; foreach ($items as $it) { $itemId = (int) ($it['item_id'] ?? 0); if ($itemId <= 0) continue; $db->table('challenge_round_item')->insert([ 'round_id' => $roundId, 'item_id' => $itemId, ]); } } else { $places = $r['places'] ?? []; foreach ($places as $placeIdx => $p) { $groupNo = $placeIdx + 1; $onboards = $p['onboards'] ?? []; $placeItems = $p['items'] ?? []; foreach ($onboards as $sel) { $placeType = (string) ($sel['type'] ?? 'onboard'); $placeId = (int) ($sel['id'] ?? 0); if ($placeId <= 0) continue; if (!in_array($placeType, ['onboard', 'fishing'], true)) continue; $db->table('challenge_round_place')->insert([ 'round_id' => $roundId, 'group_no' => $groupNo, 'place_type' => $placeType, 'place_id' => $placeId, ]); } foreach ($placeItems as $it) { $itemId = (int) ($it['item_id'] ?? 0); if ($itemId <= 0) continue; $db->table('challenge_round_group_item')->insert([ 'round_id' => $roundId, 'group_no' => $groupNo, 'item_id' => $itemId, ]); } } } } $db->transComplete(); if ($db->transStatus() === false) { return $this->respondError('수정 중 데이터베이스 오류가 발생했습니다.', ResponseInterface::HTTP_INTERNAL_SERVER_ERROR); } $row = $db->table('challenge')->where('id', (int) $id)->get()->getRow(); return $this->respondSuccess($row, '챌린지가 수정되었습니다.'); } catch (\Exception $e) { log_message('error', 'ChallengeController update error: ' . $e->getMessage()); return $this->respondError('수정 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR); } } /** * 라운드 마감 * POST /api/challenge/round/:round_id/close * 마지막 라운드 마감 시 challenge.closed_at 도 자동 설정 (자동 종료) */ public function closeRound($roundId = null) { $auth = $this->requireAuth(); if ($auth instanceof ResponseInterface) { return $auth; } if (empty($roundId)) { return $this->respondError('ID가 필요합니다.', ResponseInterface::HTTP_BAD_REQUEST); } try { $db = $this->getDB(); $round = $db->table('challenge_round') ->where('id', (int) $roundId)->get()->getRow(); if (!$round) { return $this->respondError('해당 라운드를 찾을 수 없습니다.', ResponseInterface::HTTP_NOT_FOUND); } if ($round->closed_at !== null) { return $this->respondError('이미 마감된 라운드입니다.', ResponseInterface::HTTP_BAD_REQUEST); } $now = date('Y-m-d H:i:s'); $db->transStart(); // 라운드 마감 $db->table('challenge_round') ->where('id', (int) $roundId) ->update(['closed_at' => $now]); // 모든 라운드 마감되면 challenge.closed_at 자동 설정 (closed_by NULL = 자동) $remaining = $db->table('challenge_round') ->where('challenge_id', (int) $round->challenge_id) ->where('closed_at IS NULL') ->countAllResults(); $challengeClosed = false; if ($remaining === 0) { $db->table('challenge') ->where('id', (int) $round->challenge_id) ->update([ 'closed_at' => $now, 'closed_by' => null, 'updated_at' => $now, ]); $challengeClosed = true; } $db->transComplete(); if ($db->transStatus() === false) { return $this->respondError('마감 처리 중 오류가 발생했습니다.', ResponseInterface::HTTP_INTERNAL_SERVER_ERROR); } $msg = $challengeClosed ? '마지막 라운드가 마감되어 챌린지도 종료되었습니다.' : '라운드가 마감되었습니다.'; return $this->respondSuccess(['challenge_closed' => $challengeClosed], $msg); } catch (\Exception $e) { log_message('error', 'ChallengeController closeRound error: ' . $e->getMessage()); return $this->respondError('마감 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR); } } /** * 내 임시저장 조회 * GET /api/challenge/draft */ public function showDraft() { $auth = $this->requireAuth(); if ($auth instanceof ResponseInterface) { return $auth; } try { $adminId = (int) $auth->admin_id; $row = $this->getDB()->table('challenge_draft') ->where('admin_id', $adminId) ->get() ->getRow(); if (!$row) { return $this->respondSuccess(null, '임시저장 없음'); } // data 컬럼은 JSON 문자열로 저장됐다고 가정 $row->data = is_string($row->data) ? json_decode($row->data, true) : $row->data; return $this->respondSuccess($row, '임시저장 조회 성공'); } catch (\Exception $e) { log_message('error', 'ChallengeController showDraft error: ' . $e->getMessage()); return $this->respondError('조회 중 오류: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR); } } /** * 임시저장 (upsert — 1인 1개) * POST /api/challenge/draft */ public function saveDraft() { $auth = $this->requireAuth(); if ($auth instanceof ResponseInterface) { return $auth; } try { $payload = $this->request->getJSON(true); if (!is_array($payload)) $payload = []; $adminId = (int) $auth->admin_id; $dataJson = json_encode($payload, JSON_UNESCAPED_UNICODE); $now = date('Y-m-d H:i:s'); $db = $this->getDB(); $existing = $db->table('challenge_draft') ->where('admin_id', $adminId)->get()->getRow(); if ($existing) { $db->table('challenge_draft') ->where('admin_id', $adminId) ->update([ 'data' => $dataJson, 'updated_at' => $now, ]); } else { $db->table('challenge_draft')->insert([ 'admin_id' => $adminId, 'data' => $dataJson, 'created_at' => $now, ]); } return $this->respondSuccess(['updated_at' => $now], '임시저장 완료'); } catch (\Exception $e) { log_message('error', 'ChallengeController saveDraft error: ' . $e->getMessage()); return $this->respondError('임시저장 중 오류: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR); } } /** * 임시저장 삭제 * DELETE /api/challenge/draft */ public function deleteDraft() { $auth = $this->requireAuth(); if ($auth instanceof ResponseInterface) { return $auth; } try { $adminId = (int) $auth->admin_id; $this->getDB()->table('challenge_draft') ->where('admin_id', $adminId) ->delete(); return $this->respondSuccess(null, '임시저장이 삭제되었습니다.'); } catch (\Exception $e) { log_message('error', 'ChallengeController deleteDraft error: ' . $e->getMessage()); return $this->respondError('삭제 중 오류: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR); } } /** * 챌린지 타이틀 이미지 업로드 (교체) * POST /api/challenge/:id/image */ public function uploadImage($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(); $row = $db->table($this->table) ->where('id', (int) $id)->where('deleted_YN', 'N')->get()->getRow(); if (!$row) { return $this->respondError('해당 챌린지를 찾을 수 없습니다.', ResponseInterface::HTTP_NOT_FOUND); } $file = $this->request->getFile('image'); if (!$file || !$file->isValid()) { return $this->respondError('이미지가 전송되지 않았습니다.', ResponseInterface::HTTP_BAD_REQUEST); } $allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; $mime = $file->getMimeType(); if (!in_array($mime, $allowed, true)) { return $this->respondError('이미지 형식이 올바르지 않습니다.', ResponseInterface::HTTP_BAD_REQUEST); } $uploadPath = FCPATH . 'uploads/challenge/'; if (!is_dir($uploadPath)) { mkdir($uploadPath, 0755, true); } $fileName = $file->getClientName(); $stored = $file->getRandomName(); $file->move($uploadPath, $stored); // 기존 이미지 삭제 if (!empty($row->file_path)) { $oldFull = FCPATH . ltrim($row->file_path, '/'); if (is_file($oldFull)) @unlink($oldFull); } $db->table($this->table)->where('id', (int) $id)->update([ 'file_name' => $fileName, 'file_path' => '/uploads/challenge/' . $stored, 'updated_at' => date('Y-m-d H:i:s'), ]); $updated = $db->table($this->table)->where('id', (int) $id)->get()->getRow(); return $this->respondSuccess($updated, '이미지가 교체되었습니다.'); } catch (\Exception $e) { log_message('error', 'ChallengeController uploadImage error: ' . $e->getMessage()); return $this->respondError('이미지 업로드 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR); } } /** * 챌린지 타이틀 이미지 제거 * DELETE /api/challenge/:id/image */ public function deleteImage($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(); $row = $db->table($this->table) ->where('id', (int) $id)->where('deleted_YN', 'N')->get()->getRow(); if (!$row) { return $this->respondError('해당 챌린지를 찾을 수 없습니다.', ResponseInterface::HTTP_NOT_FOUND); } if (!empty($row->file_path)) { $full = FCPATH . ltrim($row->file_path, '/'); if (is_file($full)) @unlink($full); } $db->table($this->table)->where('id', (int) $id)->update([ 'file_name' => null, 'file_path' => null, 'updated_at' => date('Y-m-d H:i:s'), ]); return $this->respondSuccess(null, '이미지가 제거되었습니다.'); } catch (\Exception $e) { log_message('error', 'ChallengeController deleteImage error: ' . $e->getMessage()); return $this->respondError('이미지 제거 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR); } } }