ChallengeController.php 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949
  1. <?php
  2. namespace App\Controllers\Api;
  3. use CodeIgniter\HTTP\ResponseInterface;
  4. class ChallengeController extends BaseApiController
  5. {
  6. protected $format = 'json';
  7. protected $table = 'challenge';
  8. /**
  9. * 챌린지 목록
  10. * GET /api/challenge/list
  11. */
  12. public function index()
  13. {
  14. $auth = $this->requireAuth();
  15. if ($auth instanceof ResponseInterface) {
  16. return $auth;
  17. }
  18. try {
  19. $page = (int) ($this->request->getGet('page') ?? 1);
  20. $perPage = (int) ($this->request->getGet('per_page') ?? 10);
  21. if ($page < 1) $page = 1;
  22. if ($perPage < 1) $perPage = 10;
  23. $offset = ($page - 1) * $perPage;
  24. $search = trim((string) $this->request->getGet('search'));
  25. $status = trim((string) $this->request->getGet('status')); // recruiting/running/ended
  26. $statusYN = trim((string) $this->request->getGet('status_YN')); // Y/N
  27. $startDate = trim((string) $this->request->getGet('start_date'));
  28. $endDate = trim((string) $this->request->getGet('end_date'));
  29. $db = $this->getDB();
  30. $builder = $db->table($this->table);
  31. $builder->where('deleted_YN', 'N');
  32. if ($search !== '') {
  33. $builder->like('name', $search);
  34. }
  35. if ($statusYN === 'Y' || $statusYN === 'N') {
  36. $builder->where('status_YN', $statusYN);
  37. }
  38. // 상태 필터 (비노출 > 명시 마감 > end_date 경과 > 모집중 > 진행중 순위)
  39. $now = date('Y-m-d H:i:s');
  40. if ($status === 'hidden') {
  41. $builder->where('status_YN', 'N');
  42. } elseif ($status === 'ended') {
  43. $builder->where('status_YN', 'Y')
  44. ->groupStart()
  45. ->where('closed_at IS NOT NULL')
  46. ->orWhere('end_date <', $now)
  47. ->groupEnd();
  48. } elseif ($status === 'recruiting') {
  49. $builder->where('status_YN', 'Y')
  50. ->where('closed_at IS NULL')
  51. ->where('end_date >=', $now)
  52. ->where('start_date >', $now);
  53. } elseif ($status === 'running') {
  54. $builder->where('status_YN', 'Y')
  55. ->where('closed_at IS NULL')
  56. ->where('end_date >=', $now)
  57. ->where('start_date <=', $now);
  58. }
  59. // 등록일 기간 필터
  60. if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate)) {
  61. $builder->where('created_at >=', $startDate . ' 00:00:00');
  62. }
  63. if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate)) {
  64. $builder->where('created_at <=', $endDate . ' 23:59:59');
  65. }
  66. $total = $builder->countAllResults(false);
  67. // 상태/현재라운드는 SQL CASE+subquery로 계산 (escape 비활성화)
  68. $items = $builder
  69. ->select("
  70. challenge.id, challenge.name, challenge.fee, challenge.start_date, challenge.end_date,
  71. challenge.max_participants, challenge.total_rounds,
  72. challenge.file_name, challenge.file_path, challenge.closed_at, challenge.closed_by,
  73. challenge.status_YN, challenge.created_at,
  74. CASE
  75. WHEN challenge.status_YN = 'N' THEN 'hidden'
  76. WHEN challenge.closed_at IS NOT NULL THEN 'ended'
  77. WHEN challenge.end_date < NOW() THEN 'ended'
  78. WHEN challenge.start_date > NOW() THEN 'recruiting'
  79. ELSE 'running'
  80. END AS derived_status,
  81. CASE
  82. WHEN challenge.closed_at IS NOT NULL THEN challenge.total_rounds
  83. WHEN challenge.end_date < NOW() THEN challenge.total_rounds
  84. ELSE COALESCE(
  85. (SELECT MIN(cr.round_no) FROM challenge_round cr
  86. WHERE cr.challenge_id = challenge.id AND cr.closed_at IS NULL),
  87. challenge.total_rounds
  88. )
  89. END AS current_round
  90. ", false)
  91. ->orderBy('challenge.id', 'DESC')
  92. ->limit($perPage, $offset)
  93. ->get()
  94. ->getResult();
  95. // 상태별 카운트 (필터 무관 — 전체 기준, 우선순위 derived_status와 동일)
  96. $countAll = $db->table($this->table)->where('deleted_YN', 'N')->countAllResults();
  97. $countHidden = $db->table($this->table)
  98. ->where('deleted_YN', 'N')
  99. ->where('status_YN', 'N')
  100. ->countAllResults();
  101. $countRecruiting = $db->table($this->table)
  102. ->where('deleted_YN', 'N')
  103. ->where('status_YN', 'Y')
  104. ->where('closed_at IS NULL')
  105. ->where('end_date >=', $now)
  106. ->where('start_date >', $now)
  107. ->countAllResults();
  108. $countRunning = $db->table($this->table)
  109. ->where('deleted_YN', 'N')
  110. ->where('status_YN', 'Y')
  111. ->where('closed_at IS NULL')
  112. ->where('end_date >=', $now)
  113. ->where('start_date <=', $now)
  114. ->countAllResults();
  115. $countEnded = $db->table($this->table)
  116. ->where('deleted_YN', 'N')
  117. ->where('status_YN', 'Y')
  118. ->groupStart()
  119. ->where('closed_at IS NOT NULL')
  120. ->orWhere('end_date <', $now)
  121. ->groupEnd()
  122. ->countAllResults();
  123. return $this->respondSuccess([
  124. 'items' => $items,
  125. 'total' => $total,
  126. 'page' => $page,
  127. 'per_page' => $perPage,
  128. 'total_pages' => (int) ceil($total / $perPage),
  129. 'counts' => [
  130. 'all' => $countAll,
  131. 'recruiting' => $countRecruiting,
  132. 'running' => $countRunning,
  133. 'ended' => $countEnded,
  134. 'hidden' => $countHidden,
  135. ],
  136. ]);
  137. } catch (\Exception $e) {
  138. log_message('error', 'ChallengeController index error: ' . $e->getMessage());
  139. return $this->respondError('목록 조회 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
  140. }
  141. }
  142. /**
  143. * 챌린지 상세 조회
  144. * GET /api/challenge/:id
  145. */
  146. public function show($id = null)
  147. {
  148. $auth = $this->requireAuth();
  149. if ($auth instanceof ResponseInterface) {
  150. return $auth;
  151. }
  152. if (empty($id)) {
  153. return $this->respondError('ID가 필요합니다.', ResponseInterface::HTTP_BAD_REQUEST);
  154. }
  155. try {
  156. $db = $this->getDB();
  157. // 1. 챌린지 기본 정보 + 상태 계산 (CASE 표현식 때문에 escape 비활성화)
  158. // detail은 시간 기반 상태만 — 노출 여부는 별도로 표시되므로 hidden 케이스 제외
  159. $row = $db->table($this->table)
  160. ->select("
  161. id, name, fee, start_date, end_date, max_participants, total_rounds,
  162. description, file_name, file_path, closed_at, closed_by,
  163. status_YN, deleted_YN, created_at, updated_at,
  164. CASE
  165. WHEN closed_at IS NOT NULL THEN 'ended'
  166. WHEN end_date < NOW() THEN 'ended'
  167. WHEN start_date > NOW() THEN 'recruiting'
  168. ELSE 'running'
  169. END AS derived_status
  170. ", false)
  171. ->where('id', (int) $id)
  172. ->where('deleted_YN', 'N')
  173. ->get()
  174. ->getRow();
  175. if (!$row) {
  176. return $this->respondError('해당 챌린지를 찾을 수 없습니다.', ResponseInterface::HTTP_NOT_FOUND);
  177. }
  178. // 2. 라운드 목록
  179. $rounds = $db->table('challenge_round')
  180. ->where('challenge_id', (int) $id)
  181. ->orderBy('round_no', 'ASC')
  182. ->get()
  183. ->getResult();
  184. foreach ($rounds as $r) {
  185. if ($r->place_mode === 'all') {
  186. // 라운드 단위 아이템
  187. $r->items = $db->table('challenge_round_item cri')
  188. ->select('cri.id, cri.item_id, i.name, i.type, i.point, i.file_name, i.file_path')
  189. ->join('item i', 'i.id = cri.item_id', 'left')
  190. ->where('cri.round_id', $r->id)
  191. ->orderBy('cri.id', 'ASC')
  192. ->get()
  193. ->getResult();
  194. $r->places = [];
  195. } else {
  196. // specific — group_no로 묶어서 묶음(place) 단위로 반환
  197. $placeRows = $db->table('challenge_round_place crp')
  198. ->select("crp.id, crp.group_no, crp.place_type, crp.place_id,
  199. COALESCE(o.name, f.name) AS place_name", false)
  200. ->join('onboard o', "o.id = crp.place_id AND crp.place_type = 'onboard'", 'left')
  201. ->join('fishing f', "f.id = crp.place_id AND crp.place_type = 'fishing'", 'left')
  202. ->where('crp.round_id', $r->id)
  203. ->orderBy('crp.group_no', 'ASC')
  204. ->orderBy('crp.id', 'ASC')
  205. ->get()
  206. ->getResult();
  207. $groups = []; // group_no => place 묶음
  208. foreach ($placeRows as $pr) {
  209. $g = (int) $pr->group_no;
  210. if (!isset($groups[$g])) {
  211. // group 단위로 아이템 조회 (round_id + group_no)
  212. $items = $db->table('challenge_round_group_item cgi')
  213. ->select('cgi.id, cgi.item_id, i.name, i.type, i.point, i.file_name, i.file_path')
  214. ->join('item i', 'i.id = cgi.item_id', 'left')
  215. ->where('cgi.round_id', $r->id)
  216. ->where('cgi.group_no', $g)
  217. ->orderBy('cgi.id', 'ASC')
  218. ->get()
  219. ->getResult();
  220. $groups[$g] = (object) [
  221. 'group_no' => $g,
  222. 'onboards' => [],
  223. 'items' => $items,
  224. ];
  225. }
  226. $groups[$g]->onboards[] = (object) [
  227. 'id' => $pr->id,
  228. 'place_type' => $pr->place_type,
  229. 'place_id' => $pr->place_id,
  230. 'place_name' => $pr->place_name,
  231. ];
  232. }
  233. $r->places = array_values($groups);
  234. $r->items = [];
  235. }
  236. }
  237. $row->rounds = $rounds;
  238. return $this->respondSuccess($row);
  239. } catch (\Exception $e) {
  240. log_message('error', 'ChallengeController show error: ' . $e->getMessage());
  241. return $this->respondError('조회 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
  242. }
  243. }
  244. /**
  245. * 챌린지 삭제 (soft delete)
  246. * DELETE /api/challenge/:id
  247. */
  248. public function delete($id = null)
  249. {
  250. $auth = $this->requireAuth();
  251. if ($auth instanceof ResponseInterface) {
  252. return $auth;
  253. }
  254. if (empty($id)) {
  255. return $this->respondError('ID가 필요합니다.', ResponseInterface::HTTP_BAD_REQUEST);
  256. }
  257. try {
  258. $db = $this->getDB();
  259. $exists = $db->table($this->table)
  260. ->where('id', (int) $id)->where('deleted_YN', 'N')->countAllResults();
  261. if ($exists === 0) {
  262. return $this->respondError('해당 챌린지를 찾을 수 없습니다.', ResponseInterface::HTTP_NOT_FOUND);
  263. }
  264. $db->table($this->table)->where('id', (int) $id)->update([
  265. 'deleted_YN' => 'Y',
  266. 'updated_at' => date('Y-m-d H:i:s'),
  267. ]);
  268. return $this->respondSuccess(null, '챌린지가 삭제되었습니다.');
  269. } catch (\Exception $e) {
  270. log_message('error', 'ChallengeController delete error: ' . $e->getMessage());
  271. return $this->respondError('삭제 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
  272. }
  273. }
  274. /**
  275. * 챌린지 등록
  276. * POST /api/challenge
  277. */
  278. public function create()
  279. {
  280. $auth = $this->requireAuth();
  281. if ($auth instanceof ResponseInterface) {
  282. return $auth;
  283. }
  284. try {
  285. $payload = $this->request->getJSON(true);
  286. if (!is_array($payload) || empty($payload)) {
  287. $payload = $this->request->getPost() ?? [];
  288. }
  289. $name = trim((string) ($payload['name'] ?? ''));
  290. $fee = trim((string) ($payload['fee'] ?? ''));
  291. $startDate = trim((string) ($payload['start_date'] ?? ''));
  292. $endDate = trim((string) ($payload['end_date'] ?? ''));
  293. $maxParticipants = (int) ($payload['max_participants'] ?? 0);
  294. $description = (string) ($payload['description'] ?? '');
  295. $status = (($payload['status_YN'] ?? 'Y') === 'N') ? 'N' : 'Y';
  296. $rounds = $payload['rounds'] ?? [];
  297. // ============================
  298. // 기본 필수값 검증
  299. // ============================
  300. if ($name === '') {
  301. return $this->respondError('챌린지명을 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
  302. }
  303. if (mb_strlen($name) > 255) {
  304. return $this->respondError('챌린지명은 255자 이내로 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
  305. }
  306. if ($fee === '') {
  307. return $this->respondError('참가비를 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
  308. }
  309. if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate)) {
  310. return $this->respondError('시작일을 올바르게 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
  311. }
  312. if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate)) {
  313. return $this->respondError('종료일을 올바르게 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
  314. }
  315. if (strtotime($endDate) < strtotime($startDate)) {
  316. return $this->respondError('종료일은 시작일과 같거나 이후여야 합니다.', ResponseInterface::HTTP_BAD_REQUEST);
  317. }
  318. if ($maxParticipants < 100 || $maxParticipants > 999999) {
  319. return $this->respondError('최대 참가자 수는 100명 이상 999,999명 이하로 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
  320. }
  321. // ============================
  322. // 라운드 검증
  323. // ============================
  324. if (!is_array($rounds) || count($rounds) < 2 || count($rounds) > 5) {
  325. return $this->respondError('라운드는 2~5개 사이여야 합니다.', ResponseInterface::HTTP_BAD_REQUEST);
  326. }
  327. foreach ($rounds as $idx => $r) {
  328. $no = $idx + 1;
  329. $placeMode = (string) ($r['place_mode'] ?? '');
  330. $qualified = (int) ($r['qualified'] ?? 0);
  331. if (!in_array($placeMode, ['all', 'specific'], true)) {
  332. return $this->respondError("라운드 {$no}의 장소 모드를 선택하세요.", ResponseInterface::HTTP_BAD_REQUEST);
  333. }
  334. if ($qualified <= 0) {
  335. return $this->respondError("라운드 {$no}의 진출자 수를 입력하세요.", ResponseInterface::HTTP_BAD_REQUEST);
  336. }
  337. if ($placeMode === 'specific') {
  338. $places = $r['places'] ?? [];
  339. if (!is_array($places) || count($places) === 0) {
  340. return $this->respondError("라운드 {$no}에 장소를 1개 이상 추가하세요.", ResponseInterface::HTTP_BAD_REQUEST);
  341. }
  342. foreach ($places as $pIdx => $p) {
  343. $pNo = $pIdx + 1;
  344. $onboards = $p['onboards'] ?? [];
  345. if (!is_array($onboards) || count($onboards) === 0) {
  346. return $this->respondError("라운드 {$no} 장소 {$pNo}에 선상을 1개 이상 선택하세요.", ResponseInterface::HTTP_BAD_REQUEST);
  347. }
  348. }
  349. }
  350. }
  351. $db = $this->getDB();
  352. $db->transStart();
  353. // ============================
  354. // 1. challenge INSERT
  355. // ============================
  356. $challengeData = [
  357. 'name' => $name,
  358. 'fee' => $fee,
  359. 'start_date' => $startDate . ' 00:00:00',
  360. 'end_date' => $endDate . ' 23:59:59',
  361. 'max_participants' => $maxParticipants,
  362. 'total_rounds' => count($rounds),
  363. 'description' => $description,
  364. 'status_YN' => $status,
  365. 'deleted_YN' => 'N',
  366. 'created_at' => date('Y-m-d H:i:s'),
  367. ];
  368. $db->table('challenge')->insert($challengeData);
  369. $challengeId = $db->insertID();
  370. if (!$challengeId) {
  371. $db->transRollback();
  372. return $this->respondError('챌린지 등록에 실패했습니다.', ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
  373. }
  374. // ============================
  375. // 2. 라운드 + 자식 테이블 INSERT
  376. // ============================
  377. foreach ($rounds as $idx => $r) {
  378. $roundNo = $idx + 1;
  379. $placeMode = $r['place_mode'];
  380. $qualified = (int) $r['qualified'];
  381. $db->table('challenge_round')->insert([
  382. 'challenge_id' => $challengeId,
  383. 'round_no' => $roundNo,
  384. 'place_mode' => $placeMode,
  385. 'qualified' => $qualified,
  386. ]);
  387. $roundId = $db->insertID();
  388. if ($placeMode === 'all') {
  389. // 라운드 단위 아이템 (0개 허용)
  390. $items = $r['items'] ?? [];
  391. foreach ($items as $it) {
  392. $itemId = (int) ($it['item_id'] ?? 0);
  393. if ($itemId <= 0) continue;
  394. $db->table('challenge_round_item')->insert([
  395. 'round_id' => $roundId,
  396. 'item_id' => $itemId,
  397. ]);
  398. }
  399. } else {
  400. // specific — 각 묶음(place)을 group_no로 식별
  401. // 장소는 선상별로 분해, 아이템은 group 단위로 1번만 INSERT
  402. $places = $r['places'] ?? [];
  403. foreach ($places as $placeIdx => $p) {
  404. $groupNo = $placeIdx + 1;
  405. $onboards = $p['onboards'] ?? []; // [{type:'onboard'|'fishing', id:int}, ...]
  406. $placeItems = $p['items'] ?? [];
  407. // 1. 묶음의 각 장소 INSERT (challenge_round_place)
  408. foreach ($onboards as $sel) {
  409. $placeType = (string) ($sel['type'] ?? 'onboard');
  410. $placeId = (int) ($sel['id'] ?? 0);
  411. if ($placeId <= 0) continue;
  412. if (!in_array($placeType, ['onboard', 'fishing'], true)) continue;
  413. $db->table('challenge_round_place')->insert([
  414. 'round_id' => $roundId,
  415. 'group_no' => $groupNo,
  416. 'place_type' => $placeType,
  417. 'place_id' => $placeId,
  418. ]);
  419. }
  420. // 2. 묶음의 아이템 INSERT (challenge_round_group_item) — group 단위 1번만
  421. foreach ($placeItems as $it) {
  422. $itemId = (int) ($it['item_id'] ?? 0);
  423. if ($itemId <= 0) continue;
  424. $db->table('challenge_round_group_item')->insert([
  425. 'round_id' => $roundId,
  426. 'group_no' => $groupNo,
  427. 'item_id' => $itemId,
  428. ]);
  429. }
  430. }
  431. }
  432. }
  433. $db->transComplete();
  434. if ($db->transStatus() === false) {
  435. return $this->respondError('등록 중 데이터베이스 오류가 발생했습니다.', ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
  436. }
  437. // 결과 응답
  438. $row = $db->table('challenge')->where('id', $challengeId)->get()->getRow();
  439. return $this->respondSuccess($row, '챌린지가 등록되었습니다.', ResponseInterface::HTTP_CREATED);
  440. } catch (\Exception $e) {
  441. log_message('error', 'ChallengeController create error: ' . $e->getMessage());
  442. return $this->respondError('등록 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
  443. }
  444. }
  445. /**
  446. * 챌린지 수정
  447. * PUT /api/challenge/:id
  448. * 주의: end_date는 등록 후 수정 불가 (정책)
  449. * 자식 데이터(라운드/장소/아이템)는 모두 DELETE 후 재구성
  450. */
  451. public function update($id = null)
  452. {
  453. $auth = $this->requireAuth();
  454. if ($auth instanceof ResponseInterface) {
  455. return $auth;
  456. }
  457. if (empty($id)) {
  458. return $this->respondError('ID가 필요합니다.', ResponseInterface::HTTP_BAD_REQUEST);
  459. }
  460. try {
  461. $payload = $this->request->getJSON(true);
  462. if (!is_array($payload) || empty($payload)) {
  463. $payload = $this->request->getRawInput() ?? [];
  464. }
  465. $name = trim((string) ($payload['name'] ?? ''));
  466. $fee = trim((string) ($payload['fee'] ?? ''));
  467. $startDate = trim((string) ($payload['start_date'] ?? ''));
  468. $endDate = trim((string) ($payload['end_date'] ?? ''));
  469. $maxParticipants = (int) ($payload['max_participants'] ?? 0);
  470. $description = (string) ($payload['description'] ?? '');
  471. $status = (($payload['status_YN'] ?? 'Y') === 'N') ? 'N' : 'Y';
  472. $rounds = $payload['rounds'] ?? [];
  473. // 필수값 검증
  474. if ($name === '') return $this->respondError('챌린지명을 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
  475. if (mb_strlen($name) > 255) return $this->respondError('챌린지명은 255자 이내로 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
  476. if ($fee === '') return $this->respondError('참가비를 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
  477. if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $startDate)) {
  478. return $this->respondError('시작일을 올바르게 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
  479. }
  480. if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $endDate)) {
  481. return $this->respondError('종료일을 올바르게 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
  482. }
  483. if (strtotime($endDate) < strtotime($startDate)) {
  484. return $this->respondError('종료일은 시작일과 같거나 이후여야 합니다.', ResponseInterface::HTTP_BAD_REQUEST);
  485. }
  486. if ($maxParticipants < 100 || $maxParticipants > 999999) {
  487. return $this->respondError('최대 참가자 수는 100명 이상 999,999명 이하로 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
  488. }
  489. // 라운드 검증
  490. if (!is_array($rounds) || count($rounds) < 2 || count($rounds) > 5) {
  491. return $this->respondError('라운드는 2~5개 사이여야 합니다.', ResponseInterface::HTTP_BAD_REQUEST);
  492. }
  493. foreach ($rounds as $idx => $r) {
  494. $no = $idx + 1;
  495. $placeMode = (string) ($r['place_mode'] ?? '');
  496. $qualified = (int) ($r['qualified'] ?? 0);
  497. if (!in_array($placeMode, ['all', 'specific'], true)) {
  498. return $this->respondError("라운드 {$no}의 장소 모드를 선택하세요.", ResponseInterface::HTTP_BAD_REQUEST);
  499. }
  500. if ($qualified <= 0) {
  501. return $this->respondError("라운드 {$no}의 진출자 수를 입력하세요.", ResponseInterface::HTTP_BAD_REQUEST);
  502. }
  503. if ($placeMode === 'specific') {
  504. $places = $r['places'] ?? [];
  505. if (!is_array($places) || count($places) === 0) {
  506. return $this->respondError("라운드 {$no}에 장소를 1개 이상 추가하세요.", ResponseInterface::HTTP_BAD_REQUEST);
  507. }
  508. foreach ($places as $pIdx => $p) {
  509. $pNo = $pIdx + 1;
  510. $onboards = $p['onboards'] ?? [];
  511. if (!is_array($onboards) || count($onboards) === 0) {
  512. return $this->respondError("라운드 {$no} 장소 {$pNo}에 선상/낚시터를 1개 이상 선택하세요.", ResponseInterface::HTTP_BAD_REQUEST);
  513. }
  514. }
  515. }
  516. }
  517. $db = $this->getDB();
  518. // 대상 존재 확인
  519. $exists = $db->table($this->table)
  520. ->where('id', (int) $id)->where('deleted_YN', 'N')->countAllResults();
  521. if ($exists === 0) {
  522. return $this->respondError('해당 챌린지를 찾을 수 없습니다.', ResponseInterface::HTTP_NOT_FOUND);
  523. }
  524. $db->transStart();
  525. // 1. challenge UPDATE
  526. $db->table('challenge')
  527. ->where('id', (int) $id)
  528. ->update([
  529. 'name' => $name,
  530. 'fee' => $fee,
  531. 'start_date' => $startDate . ' 00:00:00',
  532. 'end_date' => $endDate . ' 23:59:59',
  533. 'max_participants' => $maxParticipants,
  534. 'total_rounds' => count($rounds),
  535. 'description' => $description,
  536. 'status_YN' => $status,
  537. 'updated_at' => date('Y-m-d H:i:s'),
  538. ]);
  539. // 2. 기존 자식 데이터 모두 DELETE (CASCADE로 round_item, round_place, group_item 다 정리됨)
  540. $db->table('challenge_round')->where('challenge_id', (int) $id)->delete();
  541. // 3. 라운드 + 자식 INSERT (create 와 동일 로직)
  542. foreach ($rounds as $idx => $r) {
  543. $roundNo = $idx + 1;
  544. $placeMode = $r['place_mode'];
  545. $qualified = (int) $r['qualified'];
  546. $db->table('challenge_round')->insert([
  547. 'challenge_id' => (int) $id,
  548. 'round_no' => $roundNo,
  549. 'place_mode' => $placeMode,
  550. 'qualified' => $qualified,
  551. ]);
  552. $roundId = $db->insertID();
  553. if ($placeMode === 'all') {
  554. $items = $r['items'] ?? [];
  555. foreach ($items as $it) {
  556. $itemId = (int) ($it['item_id'] ?? 0);
  557. if ($itemId <= 0) continue;
  558. $db->table('challenge_round_item')->insert([
  559. 'round_id' => $roundId,
  560. 'item_id' => $itemId,
  561. ]);
  562. }
  563. } else {
  564. $places = $r['places'] ?? [];
  565. foreach ($places as $placeIdx => $p) {
  566. $groupNo = $placeIdx + 1;
  567. $onboards = $p['onboards'] ?? [];
  568. $placeItems = $p['items'] ?? [];
  569. foreach ($onboards as $sel) {
  570. $placeType = (string) ($sel['type'] ?? 'onboard');
  571. $placeId = (int) ($sel['id'] ?? 0);
  572. if ($placeId <= 0) continue;
  573. if (!in_array($placeType, ['onboard', 'fishing'], true)) continue;
  574. $db->table('challenge_round_place')->insert([
  575. 'round_id' => $roundId,
  576. 'group_no' => $groupNo,
  577. 'place_type' => $placeType,
  578. 'place_id' => $placeId,
  579. ]);
  580. }
  581. foreach ($placeItems as $it) {
  582. $itemId = (int) ($it['item_id'] ?? 0);
  583. if ($itemId <= 0) continue;
  584. $db->table('challenge_round_group_item')->insert([
  585. 'round_id' => $roundId,
  586. 'group_no' => $groupNo,
  587. 'item_id' => $itemId,
  588. ]);
  589. }
  590. }
  591. }
  592. }
  593. $db->transComplete();
  594. if ($db->transStatus() === false) {
  595. return $this->respondError('수정 중 데이터베이스 오류가 발생했습니다.', ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
  596. }
  597. $row = $db->table('challenge')->where('id', (int) $id)->get()->getRow();
  598. return $this->respondSuccess($row, '챌린지가 수정되었습니다.');
  599. } catch (\Exception $e) {
  600. log_message('error', 'ChallengeController update error: ' . $e->getMessage());
  601. return $this->respondError('수정 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
  602. }
  603. }
  604. /**
  605. * 라운드 마감
  606. * POST /api/challenge/round/:round_id/close
  607. * 마지막 라운드 마감 시 challenge.closed_at 도 자동 설정 (자동 종료)
  608. */
  609. public function closeRound($roundId = null)
  610. {
  611. $auth = $this->requireAuth();
  612. if ($auth instanceof ResponseInterface) {
  613. return $auth;
  614. }
  615. if (empty($roundId)) {
  616. return $this->respondError('ID가 필요합니다.', ResponseInterface::HTTP_BAD_REQUEST);
  617. }
  618. try {
  619. $db = $this->getDB();
  620. $round = $db->table('challenge_round')
  621. ->where('id', (int) $roundId)->get()->getRow();
  622. if (!$round) {
  623. return $this->respondError('해당 라운드를 찾을 수 없습니다.', ResponseInterface::HTTP_NOT_FOUND);
  624. }
  625. if ($round->closed_at !== null) {
  626. return $this->respondError('이미 마감된 라운드입니다.', ResponseInterface::HTTP_BAD_REQUEST);
  627. }
  628. $now = date('Y-m-d H:i:s');
  629. $db->transStart();
  630. // 라운드 마감
  631. $db->table('challenge_round')
  632. ->where('id', (int) $roundId)
  633. ->update(['closed_at' => $now]);
  634. // 모든 라운드 마감되면 challenge.closed_at 자동 설정 (closed_by NULL = 자동)
  635. $remaining = $db->table('challenge_round')
  636. ->where('challenge_id', (int) $round->challenge_id)
  637. ->where('closed_at IS NULL')
  638. ->countAllResults();
  639. $challengeClosed = false;
  640. if ($remaining === 0) {
  641. $db->table('challenge')
  642. ->where('id', (int) $round->challenge_id)
  643. ->update([
  644. 'closed_at' => $now,
  645. 'closed_by' => null,
  646. 'updated_at' => $now,
  647. ]);
  648. $challengeClosed = true;
  649. }
  650. $db->transComplete();
  651. if ($db->transStatus() === false) {
  652. return $this->respondError('마감 처리 중 오류가 발생했습니다.', ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
  653. }
  654. $msg = $challengeClosed
  655. ? '마지막 라운드가 마감되어 챌린지도 종료되었습니다.'
  656. : '라운드가 마감되었습니다.';
  657. return $this->respondSuccess(['challenge_closed' => $challengeClosed], $msg);
  658. } catch (\Exception $e) {
  659. log_message('error', 'ChallengeController closeRound error: ' . $e->getMessage());
  660. return $this->respondError('마감 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
  661. }
  662. }
  663. /**
  664. * 내 임시저장 조회
  665. * GET /api/challenge/draft
  666. */
  667. public function showDraft()
  668. {
  669. $auth = $this->requireAuth();
  670. if ($auth instanceof ResponseInterface) {
  671. return $auth;
  672. }
  673. try {
  674. $adminId = (int) $auth->admin_id;
  675. $row = $this->getDB()->table('challenge_draft')
  676. ->where('admin_id', $adminId)
  677. ->get()
  678. ->getRow();
  679. if (!$row) {
  680. return $this->respondSuccess(null, '임시저장 없음');
  681. }
  682. // data 컬럼은 JSON 문자열로 저장됐다고 가정
  683. $row->data = is_string($row->data) ? json_decode($row->data, true) : $row->data;
  684. return $this->respondSuccess($row, '임시저장 조회 성공');
  685. } catch (\Exception $e) {
  686. log_message('error', 'ChallengeController showDraft error: ' . $e->getMessage());
  687. return $this->respondError('조회 중 오류: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
  688. }
  689. }
  690. /**
  691. * 임시저장 (upsert — 1인 1개)
  692. * POST /api/challenge/draft
  693. */
  694. public function saveDraft()
  695. {
  696. $auth = $this->requireAuth();
  697. if ($auth instanceof ResponseInterface) {
  698. return $auth;
  699. }
  700. try {
  701. $payload = $this->request->getJSON(true);
  702. if (!is_array($payload)) $payload = [];
  703. $adminId = (int) $auth->admin_id;
  704. $dataJson = json_encode($payload, JSON_UNESCAPED_UNICODE);
  705. $now = date('Y-m-d H:i:s');
  706. $db = $this->getDB();
  707. $existing = $db->table('challenge_draft')
  708. ->where('admin_id', $adminId)->get()->getRow();
  709. if ($existing) {
  710. $db->table('challenge_draft')
  711. ->where('admin_id', $adminId)
  712. ->update([
  713. 'data' => $dataJson,
  714. 'updated_at' => $now,
  715. ]);
  716. } else {
  717. $db->table('challenge_draft')->insert([
  718. 'admin_id' => $adminId,
  719. 'data' => $dataJson,
  720. 'created_at' => $now,
  721. ]);
  722. }
  723. return $this->respondSuccess(['updated_at' => $now], '임시저장 완료');
  724. } catch (\Exception $e) {
  725. log_message('error', 'ChallengeController saveDraft error: ' . $e->getMessage());
  726. return $this->respondError('임시저장 중 오류: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
  727. }
  728. }
  729. /**
  730. * 임시저장 삭제
  731. * DELETE /api/challenge/draft
  732. */
  733. public function deleteDraft()
  734. {
  735. $auth = $this->requireAuth();
  736. if ($auth instanceof ResponseInterface) {
  737. return $auth;
  738. }
  739. try {
  740. $adminId = (int) $auth->admin_id;
  741. $this->getDB()->table('challenge_draft')
  742. ->where('admin_id', $adminId)
  743. ->delete();
  744. return $this->respondSuccess(null, '임시저장이 삭제되었습니다.');
  745. } catch (\Exception $e) {
  746. log_message('error', 'ChallengeController deleteDraft error: ' . $e->getMessage());
  747. return $this->respondError('삭제 중 오류: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
  748. }
  749. }
  750. /**
  751. * 챌린지 타이틀 이미지 업로드 (교체)
  752. * POST /api/challenge/:id/image
  753. */
  754. public function uploadImage($id = null)
  755. {
  756. $auth = $this->requireAuth();
  757. if ($auth instanceof ResponseInterface) {
  758. return $auth;
  759. }
  760. if (empty($id)) {
  761. return $this->respondError('ID가 필요합니다.', ResponseInterface::HTTP_BAD_REQUEST);
  762. }
  763. try {
  764. $db = $this->getDB();
  765. $row = $db->table($this->table)
  766. ->where('id', (int) $id)->where('deleted_YN', 'N')->get()->getRow();
  767. if (!$row) {
  768. return $this->respondError('해당 챌린지를 찾을 수 없습니다.', ResponseInterface::HTTP_NOT_FOUND);
  769. }
  770. $file = $this->request->getFile('image');
  771. if (!$file || !$file->isValid()) {
  772. return $this->respondError('이미지가 전송되지 않았습니다.', ResponseInterface::HTTP_BAD_REQUEST);
  773. }
  774. $allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
  775. $mime = $file->getMimeType();
  776. if (!in_array($mime, $allowed, true)) {
  777. return $this->respondError('이미지 형식이 올바르지 않습니다.', ResponseInterface::HTTP_BAD_REQUEST);
  778. }
  779. $uploadPath = FCPATH . 'uploads/challenge/';
  780. if (!is_dir($uploadPath)) {
  781. mkdir($uploadPath, 0755, true);
  782. }
  783. $fileName = $file->getClientName();
  784. $stored = $file->getRandomName();
  785. $file->move($uploadPath, $stored);
  786. // 기존 이미지 삭제
  787. if (!empty($row->file_path)) {
  788. $oldFull = FCPATH . ltrim($row->file_path, '/');
  789. if (is_file($oldFull)) @unlink($oldFull);
  790. }
  791. $db->table($this->table)->where('id', (int) $id)->update([
  792. 'file_name' => $fileName,
  793. 'file_path' => '/uploads/challenge/' . $stored,
  794. 'updated_at' => date('Y-m-d H:i:s'),
  795. ]);
  796. $updated = $db->table($this->table)->where('id', (int) $id)->get()->getRow();
  797. return $this->respondSuccess($updated, '이미지가 교체되었습니다.');
  798. } catch (\Exception $e) {
  799. log_message('error', 'ChallengeController uploadImage error: ' . $e->getMessage());
  800. return $this->respondError('이미지 업로드 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
  801. }
  802. }
  803. /**
  804. * 챌린지 타이틀 이미지 제거
  805. * DELETE /api/challenge/:id/image
  806. */
  807. public function deleteImage($id = null)
  808. {
  809. $auth = $this->requireAuth();
  810. if ($auth instanceof ResponseInterface) {
  811. return $auth;
  812. }
  813. if (empty($id)) {
  814. return $this->respondError('ID가 필요합니다.', ResponseInterface::HTTP_BAD_REQUEST);
  815. }
  816. try {
  817. $db = $this->getDB();
  818. $row = $db->table($this->table)
  819. ->where('id', (int) $id)->where('deleted_YN', 'N')->get()->getRow();
  820. if (!$row) {
  821. return $this->respondError('해당 챌린지를 찾을 수 없습니다.', ResponseInterface::HTTP_NOT_FOUND);
  822. }
  823. if (!empty($row->file_path)) {
  824. $full = FCPATH . ltrim($row->file_path, '/');
  825. if (is_file($full)) @unlink($full);
  826. }
  827. $db->table($this->table)->where('id', (int) $id)->update([
  828. 'file_name' => null,
  829. 'file_path' => null,
  830. 'updated_at' => date('Y-m-d H:i:s'),
  831. ]);
  832. return $this->respondSuccess(null, '이미지가 제거되었습니다.');
  833. } catch (\Exception $e) {
  834. log_message('error', 'ChallengeController deleteImage error: ' . $e->getMessage());
  835. return $this->respondError('이미지 제거 중 오류가 발생했습니다: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
  836. }
  837. }
  838. }