UsersController.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  1. <?php
  2. namespace App\Controllers\Api;
  3. use CodeIgniter\HTTP\ResponseInterface;
  4. class UsersController extends BaseApiController
  5. {
  6. protected $format = 'json';
  7. protected $table = 'users';
  8. private const ALLOWED_AREAS = ['남해', '동해', '민물', '서해', '제주'];
  9. /**
  10. * 회원 로그인
  11. * POST /api/users/login
  12. */
  13. public function login()
  14. {
  15. try {
  16. $payload = $this->request->getJSON(true);
  17. if (!is_array($payload)) $payload = [];
  18. $username = trim((string) ($payload['username'] ?? ''));
  19. $password = (string) ($payload['password'] ?? '');
  20. $autoLogin = !empty($payload['auto_login']);
  21. if ($username === '' || $password === '') {
  22. return $this->respondError('아이디와 비밀번호를 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
  23. }
  24. $db = $this->getDB();
  25. $user = $db->table($this->table)
  26. ->where('username', $username)
  27. ->where('deleted_YN', 'N')
  28. ->get()
  29. ->getRow();
  30. if (!$user) {
  31. return $this->respondError('아이디 또는 비밀번호가 일치하지 않습니다.', ResponseInterface::HTTP_UNAUTHORIZED);
  32. }
  33. // 계정 상태 확인
  34. if ($user->status === 'inactive') {
  35. return $this->respondError('비활성화된 계정입니다.', ResponseInterface::HTTP_FORBIDDEN);
  36. }
  37. if ($user->status === 'suspended') {
  38. return $this->respondError('정지된 계정입니다. 고객센터에 문의하세요.', ResponseInterface::HTTP_FORBIDDEN);
  39. }
  40. // 비밀번호 검증
  41. if (!password_verify($password, $user->password)) {
  42. return $this->respondError('아이디 또는 비밀번호가 일치하지 않습니다.', ResponseInterface::HTTP_UNAUTHORIZED);
  43. }
  44. // last_login_at 업데이트
  45. $now = date('Y-m-d H:i:s');
  46. $db->table($this->table)->where('id', $user->id)->update([
  47. 'last_login_at' => $now,
  48. ]);
  49. // 토큰 발급 + 저장 (자동로그인: 30일 / 일반: 24시간)
  50. $token = bin2hex(random_bytes(32));
  51. $expiresAt = $autoLogin
  52. ? date('Y-m-d H:i:s', strtotime('+30 days'))
  53. : date('Y-m-d H:i:s', strtotime('+24 hours'));
  54. $db->table('user_tokens')->insert([
  55. 'user_id' => $user->id,
  56. 'token' => $token,
  57. 'expires_at' => $expiresAt,
  58. 'created_at' => $now,
  59. ]);
  60. return $this->respondSuccess([
  61. 'token' => $token,
  62. 'expires_at' => $expiresAt,
  63. 'user' => [
  64. 'id' => (int) $user->id,
  65. 'username' => $user->username,
  66. 'nickname' => $user->nickname,
  67. 'name' => $user->name,
  68. 'phone' => $user->phone,
  69. 'prefer_area' => $user->prefer_area,
  70. 'profile_image'=> $user->profile_image,
  71. ],
  72. ], '로그인 성공');
  73. } catch (\Exception $e) {
  74. log_message('error', 'login error: ' . $e->getMessage());
  75. return $this->respondError('로그인 중 오류: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
  76. }
  77. }
  78. /**
  79. * 회원 로그아웃
  80. * POST /api/users/logout
  81. */
  82. public function logout()
  83. {
  84. try {
  85. $authHeader = $this->getAuthHeader();
  86. if (!empty($authHeader)) {
  87. $token = str_replace('Bearer ', '', $authHeader);
  88. if (!empty($token)) {
  89. $this->getDB()->table('user_tokens')->where('token', $token)->delete();
  90. }
  91. }
  92. return $this->respondSuccess(null, '로그아웃 성공');
  93. } catch (\Exception $e) {
  94. log_message('error', 'logout error: ' . $e->getMessage());
  95. return $this->respondError('로그아웃 중 오류: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
  96. }
  97. }
  98. /**
  99. * 아이디 중복 확인
  100. * POST /api/users/check-username
  101. */
  102. public function checkUsername()
  103. {
  104. try {
  105. $payload = $this->request->getJSON(true);
  106. $username = trim((string) ($payload['username'] ?? ''));
  107. if ($username === '') {
  108. return $this->respondError('아이디를 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
  109. }
  110. if (!preg_match('/^[a-zA-Z0-9]{6,20}$/', $username)) {
  111. return $this->respondError('영문/숫자 6~20자로 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
  112. }
  113. $exists = $this->getDB()->table($this->table)
  114. ->where('username', $username)
  115. ->where('deleted_YN', 'N')
  116. ->countAllResults();
  117. if ($exists > 0) {
  118. return $this->respondError('이미 사용 중인 아이디입니다.', ResponseInterface::HTTP_BAD_REQUEST);
  119. }
  120. return $this->respondSuccess(null, '사용 가능한 아이디입니다.');
  121. } catch (\Exception $e) {
  122. log_message('error', 'checkUsername error: ' . $e->getMessage());
  123. return $this->respondError('확인 중 오류: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
  124. }
  125. }
  126. /**
  127. * 닉네임 중복 확인
  128. * POST /api/users/check-nickname
  129. */
  130. public function checkNickname()
  131. {
  132. try {
  133. $payload = $this->request->getJSON(true);
  134. $nickname = trim((string) ($payload['nickname'] ?? ''));
  135. if ($nickname === '') {
  136. return $this->respondError('닉네임을 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
  137. }
  138. if (mb_strlen($nickname) < 2 || mb_strlen($nickname) > 20) {
  139. return $this->respondError('닉네임은 2~20자로 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
  140. }
  141. $exists = $this->getDB()->table($this->table)
  142. ->where('nickname', $nickname)
  143. ->where('deleted_YN', 'N')
  144. ->countAllResults();
  145. if ($exists > 0) {
  146. return $this->respondError('이미 사용 중인 닉네임입니다.', ResponseInterface::HTTP_BAD_REQUEST);
  147. }
  148. return $this->respondSuccess(null, '사용 가능한 닉네임입니다.');
  149. } catch (\Exception $e) {
  150. log_message('error', 'checkNickname error: ' . $e->getMessage());
  151. return $this->respondError('확인 중 오류: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
  152. }
  153. }
  154. /**
  155. * 회원가입
  156. * POST /api/users/signup
  157. */
  158. public function signup()
  159. {
  160. try {
  161. $payload = $this->request->getJSON(true);
  162. if (!is_array($payload)) $payload = [];
  163. $username = trim((string) ($payload['username'] ?? ''));
  164. $password = (string) ($payload['password'] ?? '');
  165. $name = trim((string) ($payload['name'] ?? ''));
  166. $phone = trim((string) ($payload['phone'] ?? ''));
  167. $nickname = trim((string) ($payload['nickname'] ?? ''));
  168. $preferArea = trim((string) ($payload['prefer_area'] ?? ''));
  169. $marketingAgree = (($payload['marketing_agree_YN'] ?? 'N') === 'Y') ? 'Y' : 'N';
  170. // 검증
  171. if (!preg_match('/^[a-zA-Z0-9]{6,20}$/', $username)) {
  172. return $this->respondError('아이디는 영문/숫자 6~20자로 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
  173. }
  174. if (!preg_match('/^(?=.*[a-z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=\[\]{};\':"\\|,.<>\/?]).{8,}$/', $password)) {
  175. return $this->respondError('비밀번호는 영문 소문자+숫자+특수문자 조합 8자 이상으로 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
  176. }
  177. if ($name === '' || mb_strlen($name) > 50) {
  178. return $this->respondError('이름을 올바르게 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
  179. }
  180. // 핸드폰 정규화: 숫자만 추출 후 0XX-XXXX-XXXX 형식으로
  181. // 010 / 011 / 016 / 017 / 018 / 019 모두 허용 (10~11자리)
  182. $phoneDigits = preg_replace('/[^0-9]/', '', $phone);
  183. if (!preg_match('/^01[016789]\d{7,8}$/', $phoneDigits)) {
  184. return $this->respondError('핸드폰 번호를 올바르게 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
  185. }
  186. // 11자리: 3-4-4 / 10자리: 3-3-4
  187. if (strlen($phoneDigits) === 11) {
  188. $phoneFormatted = substr($phoneDigits, 0, 3) . '-' . substr($phoneDigits, 3, 4) . '-' . substr($phoneDigits, 7);
  189. } else {
  190. $phoneFormatted = substr($phoneDigits, 0, 3) . '-' . substr($phoneDigits, 3, 3) . '-' . substr($phoneDigits, 6);
  191. }
  192. if (mb_strlen($nickname) < 2 || mb_strlen($nickname) > 20) {
  193. return $this->respondError('닉네임은 2~20자로 입력하세요.', ResponseInterface::HTTP_BAD_REQUEST);
  194. }
  195. // 선호지역 검증
  196. $preferAreaNormalized = null;
  197. if ($preferArea !== '') {
  198. $areas = array_filter(array_map('trim', explode(',', $preferArea)));
  199. foreach ($areas as $a) {
  200. if (!in_array($a, self::ALLOWED_AREAS, true)) {
  201. return $this->respondError('잘못된 선호지역입니다.', ResponseInterface::HTTP_BAD_REQUEST);
  202. }
  203. }
  204. $preferAreaNormalized = implode(',', array_values(array_unique($areas)));
  205. }
  206. $db = $this->getDB();
  207. // 중복 체크
  208. $userExists = $db->table($this->table)
  209. ->where('username', $username)
  210. ->where('deleted_YN', 'N')
  211. ->countAllResults();
  212. if ($userExists > 0) {
  213. return $this->respondError('이미 사용 중인 아이디입니다.', ResponseInterface::HTTP_BAD_REQUEST);
  214. }
  215. $nickExists = $db->table($this->table)
  216. ->where('nickname', $nickname)
  217. ->where('deleted_YN', 'N')
  218. ->countAllResults();
  219. if ($nickExists > 0) {
  220. return $this->respondError('이미 사용 중인 닉네임입니다.', ResponseInterface::HTTP_BAD_REQUEST);
  221. }
  222. // INSERT
  223. $db->table($this->table)->insert([
  224. 'username' => $username,
  225. 'password' => password_hash($password, PASSWORD_DEFAULT),
  226. 'nickname' => $nickname,
  227. 'name' => $name,
  228. 'phone' => $phoneFormatted,
  229. 'prefer_area' => $preferAreaNormalized,
  230. 'signup_type' => 'local',
  231. 'marketing_agree_YN' => $marketingAgree,
  232. 'status' => 'active',
  233. 'deleted_YN' => 'N',
  234. 'created_at' => date('Y-m-d H:i:s'),
  235. ]);
  236. $newId = $db->insertID();
  237. return $this->respondSuccess(
  238. ['id' => $newId, 'username' => $username, 'nickname' => $nickname],
  239. '회원가입이 완료되었습니다.',
  240. ResponseInterface::HTTP_CREATED
  241. );
  242. } catch (\Exception $e) {
  243. log_message('error', 'signup error: ' . $e->getMessage());
  244. return $this->respondError('회원가입 중 오류: ' . $e->getMessage(), ResponseInterface::HTTP_INTERNAL_SERVER_ERROR);
  245. }
  246. }
  247. }