request->getJSON(true); if (!is_array($payload)) $payload = []; $username = trim((string) ($payload['username'] ?? '')); $password = (string) ($payload['password'] ?? ''); $autoLogin = !empty($payload['auto_login']); if ($username === '' || $password === '') { return $this->respondError('아이디와 비밀번호를 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST); } $db = $this->getDB(); $user = $db->table($this->table) ->where('username', $username) ->where('deleted_YN', 'N') ->get() ->getRow(); if (!$user) { return $this->respondError('아이디 또는 비밀번호가 일치하지 않습니다.', ResponseInterface::HTTP_UNAUTHORIZED); } // 계정 상태 확인 if ($user->status === 'inactive') { return $this->respondError('비활성화된 계정입니다.', ResponseInterface::HTTP_FORBIDDEN); } if ($user->status === 'suspended') { return $this->respondError('정지된 계정입니다. 고객센터에 문의하세요.', ResponseInterface::HTTP_FORBIDDEN); } // 비밀번호 검증 if (!password_verify($password, $user->password)) { return $this->respondError('아이디 또는 비밀번호가 일치하지 않습니다.', ResponseInterface::HTTP_UNAUTHORIZED); } // last_login_at 업데이트 $now = date('Y-m-d H:i:s'); $db->table($this->table)->where('id', $user->id)->update([ 'last_login_at' => $now, ]); // 토큰 발급 + 저장 (자동로그인: 30일 / 일반: 24시간) $token = bin2hex(random_bytes(32)); $expiresAt = $autoLogin ? date('Y-m-d H:i:s', strtotime('+30 days')) : date('Y-m-d H:i:s', strtotime('+24 hours')); $db->table('user_tokens')->insert([ 'user_id' => $user->id, 'token' => $token, 'expires_at' => $expiresAt, 'created_at' => $now, ]); return $this->respondSuccess([ 'token' => $token, 'expires_at' => $expiresAt, 'user' => [ 'id' => (int) $user->id, 'username' => $user->username, 'nickname' => $user->nickname, 'name' => $user->name, 'phone' => $user->phone, 'prefer_area' => $user->prefer_area, 'profile_image'=> $user->profile_image, ], ], '로그인 성공'); } catch (\Exception $e) { log_message('error', 'login error: ' . $e->getMessage()); return $this->respondError('로그인 중 오류: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR); } } /** * 회원 로그아웃 * POST /api/users/logout */ public function logout() { try { $authHeader = $this->getAuthHeader(); if (!empty($authHeader)) { $token = str_replace('Bearer ', '', $authHeader); if (!empty($token)) { $this->getDB()->table('user_tokens')->where('token', $token)->delete(); } } return $this->respondSuccess(null, '로그아웃 성공'); } catch (\Exception $e) { log_message('error', 'logout error: ' . $e->getMessage()); return $this->respondError('로그아웃 중 오류: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR); } } /** * 아이디 중복 확인 * POST /api/users/check-username */ public function checkUsername() { try { $payload = $this->request->getJSON(true); $username = trim((string) ($payload['username'] ?? '')); if ($username === '') { return $this->respondError('아이디를 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST); } if (!preg_match('/^[a-zA-Z0-9]{6,20}$/', $username)) { return $this->respondError('영문/숫자 6~20자로 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST); } $exists = $this->getDB()->table($this->table) ->where('username', $username) ->where('deleted_YN', 'N') ->countAllResults(); if ($exists > 0) { return $this->respondError('이미 사용 중인 아이디입니다.', ResponseInterface::HTTP_BAD_REQUEST); } return $this->respondSuccess(null, '사용 가능한 아이디입니다.'); } catch (\Exception $e) { log_message('error', 'checkUsername error: ' . $e->getMessage()); return $this->respondError('확인 중 오류: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR); } } /** * 닉네임 중복 확인 * POST /api/users/check-nickname */ public function checkNickname() { try { $payload = $this->request->getJSON(true); $nickname = trim((string) ($payload['nickname'] ?? '')); if ($nickname === '') { return $this->respondError('닉네임을 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST); } if (mb_strlen($nickname) < 2 || mb_strlen($nickname) > 20) { return $this->respondError('닉네임은 2~20자로 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST); } $exists = $this->getDB()->table($this->table) ->where('nickname', $nickname) ->where('deleted_YN', 'N') ->countAllResults(); if ($exists > 0) { return $this->respondError('이미 사용 중인 닉네임입니다.', ResponseInterface::HTTP_BAD_REQUEST); } return $this->respondSuccess(null, '사용 가능한 닉네임입니다.'); } catch (\Exception $e) { log_message('error', 'checkNickname error: ' . $e->getMessage()); return $this->respondError('확인 중 오류: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR); } } /** * 회원가입 * POST /api/users/signup */ public function signup() { try { $payload = $this->request->getJSON(true); if (!is_array($payload)) $payload = []; $username = trim((string) ($payload['username'] ?? '')); $password = (string) ($payload['password'] ?? ''); $name = trim((string) ($payload['name'] ?? '')); $phone = trim((string) ($payload['phone'] ?? '')); $nickname = trim((string) ($payload['nickname'] ?? '')); $preferArea = trim((string) ($payload['prefer_area'] ?? '')); $marketingAgree = (($payload['marketing_agree_YN'] ?? 'N') === 'Y') ? 'Y' : 'N'; // 검증 if (!preg_match('/^[a-zA-Z0-9]{6,20}$/', $username)) { return $this->respondError('아이디는 영문/숫자 6~20자로 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST); } if (!preg_match('/^(?=.*[a-z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=\[\]{};\':"\\|,.<>\/?]).{8,}$/', $password)) { return $this->respondError('비밀번호는 영문 소문자+숫자+특수문자 조합 8자 이상으로 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST); } if ($name === '' || mb_strlen($name) > 50) { return $this->respondError('이름을 올바르게 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST); } // 핸드폰 정규화: 숫자만 추출 후 0XX-XXXX-XXXX 형식으로 // 010 / 011 / 016 / 017 / 018 / 019 모두 허용 (10~11자리) $phoneDigits = preg_replace('/[^0-9]/', '', $phone); if (!preg_match('/^01[016789]\d{7,8}$/', $phoneDigits)) { return $this->respondError('핸드폰 번호를 올바르게 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST); } // 11자리: 3-4-4 / 10자리: 3-3-4 if (strlen($phoneDigits) === 11) { $phoneFormatted = substr($phoneDigits, 0, 3) . '-' . substr($phoneDigits, 3, 4) . '-' . substr($phoneDigits, 7); } else { $phoneFormatted = substr($phoneDigits, 0, 3) . '-' . substr($phoneDigits, 3, 3) . '-' . substr($phoneDigits, 6); } if (mb_strlen($nickname) < 2 || mb_strlen($nickname) > 20) { return $this->respondError('닉네임은 2~20자로 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST); } // 선호지역 검증 $preferAreaNormalized = null; if ($preferArea !== '') { $areas = array_filter(array_map('trim', explode(',', $preferArea))); foreach ($areas as $a) { if (!in_array($a, self::ALLOWED_AREAS, true)) { return $this->respondError('잘못된 선호지역입니다.', ResponseInterface::HTTP_BAD_REQUEST); } } $preferAreaNormalized = implode(',', array_values(array_unique($areas))); } $db = $this->getDB(); // 중복 체크 $userExists = $db->table($this->table) ->where('username', $username) ->where('deleted_YN', 'N') ->countAllResults(); if ($userExists > 0) { return $this->respondError('이미 사용 중인 아이디입니다.', ResponseInterface::HTTP_BAD_REQUEST); } $nickExists = $db->table($this->table) ->where('nickname', $nickname) ->where('deleted_YN', 'N') ->countAllResults(); if ($nickExists > 0) { return $this->respondError('이미 사용 중인 닉네임입니다.', ResponseInterface::HTTP_BAD_REQUEST); } // INSERT $db->table($this->table)->insert([ 'username' => $username, 'password' => password_hash($password, PASSWORD_DEFAULT), 'nickname' => $nickname, 'name' => $name, 'phone' => $phoneFormatted, 'prefer_area' => $preferAreaNormalized, 'signup_type' => 'local', 'marketing_agree_YN' => $marketingAgree, 'status' => 'active', 'deleted_YN' => 'N', 'created_at' => date('Y-m-d H:i:s'), ]); $newId = $db->insertID(); return $this->respondSuccess( ['id' => $newId, 'username' => $username, 'nickname' => $nickname], '회원가입이 완료되었습니다.', ResponseInterface::HTTP_CREATED ); } catch (\Exception $e) { log_message('error', 'signup error: ' . $e->getMessage()); return $this->respondError('회원가입 중 오류: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR); } } }