diff --git a/backend/src/main/java/io/f1/backend/domain/admin/api/AdminController.java b/backend/src/main/java/io/f1/backend/domain/admin/api/AdminController.java new file mode 100644 index 00000000..096681d7 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/admin/api/AdminController.java @@ -0,0 +1,28 @@ +package io.f1.backend.domain.admin.api; + +import io.f1.backend.domain.admin.app.AdminService; +import io.f1.backend.domain.admin.dto.UserPageResponse; +import io.f1.backend.global.validation.LimitPageSize; + +import lombok.RequiredArgsConstructor; + +import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/admin") +@RequiredArgsConstructor +public class AdminController { + + private final AdminService adminService; + + @LimitPageSize + @GetMapping("/users") + public ResponseEntity getUsers(Pageable pageable) { + UserPageResponse response = adminService.getAllUsers(pageable); + return ResponseEntity.ok().body(response); + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/admin/app/AdminService.java b/backend/src/main/java/io/f1/backend/domain/admin/app/AdminService.java new file mode 100644 index 00000000..66621538 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/admin/app/AdminService.java @@ -0,0 +1,27 @@ +package io.f1.backend.domain.admin.app; + +import static io.f1.backend.domain.admin.mapper.AdminMapper.toUserListPageResponse; + +import io.f1.backend.domain.admin.dto.UserPageResponse; +import io.f1.backend.domain.admin.dto.UserResponse; +import io.f1.backend.domain.user.dao.UserRepository; + +import lombok.RequiredArgsConstructor; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AdminService { + + private final UserRepository userRepository; + + @Transactional(readOnly = true) + public UserPageResponse getAllUsers(Pageable pageable) { + Page users = userRepository.findAllUsersWithPaging(pageable); + return toUserListPageResponse(users); + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/admin/dto/UserPageResponse.java b/backend/src/main/java/io/f1/backend/domain/admin/dto/UserPageResponse.java new file mode 100644 index 00000000..98fa0841 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/admin/dto/UserPageResponse.java @@ -0,0 +1,6 @@ +package io.f1.backend.domain.admin.dto; + +import java.util.List; + +public record UserPageResponse( + int totalPages, int currentPage, int totalElements, List users) {} diff --git a/backend/src/main/java/io/f1/backend/domain/admin/dto/UserResponse.java b/backend/src/main/java/io/f1/backend/domain/admin/dto/UserResponse.java new file mode 100644 index 00000000..fa826967 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/admin/dto/UserResponse.java @@ -0,0 +1,6 @@ +package io.f1.backend.domain.admin.dto; + +import java.time.LocalDateTime; + +public record UserResponse( + Long id, String nickname, LocalDateTime lastLogin, LocalDateTime createdAt) {} diff --git a/backend/src/main/java/io/f1/backend/domain/admin/entity/Admin.java b/backend/src/main/java/io/f1/backend/domain/admin/entity/Admin.java index f457e6dc..efe255fa 100644 --- a/backend/src/main/java/io/f1/backend/domain/admin/entity/Admin.java +++ b/backend/src/main/java/io/f1/backend/domain/admin/entity/Admin.java @@ -8,12 +8,15 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import lombok.Builder; import lombok.Getter; +import lombok.NoArgsConstructor; import java.time.LocalDateTime; @Entity @Getter +@NoArgsConstructor public class Admin extends BaseEntity { @Id @@ -32,4 +35,12 @@ public class Admin extends BaseEntity { public void updateLastLogin(LocalDateTime lastLogin) { this.lastLogin = lastLogin; } + + @Builder + public Admin(Long id, String username, String password, LocalDateTime lastLogin) { + this.id = id; + this.username = username; + this.password = password; + this.lastLogin = LocalDateTime.now(); + } } diff --git a/backend/src/main/java/io/f1/backend/domain/admin/mapper/AdminMapper.java b/backend/src/main/java/io/f1/backend/domain/admin/mapper/AdminMapper.java new file mode 100644 index 00000000..6b877e58 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/admin/mapper/AdminMapper.java @@ -0,0 +1,21 @@ +package io.f1.backend.domain.admin.mapper; + +import io.f1.backend.domain.admin.dto.UserPageResponse; +import io.f1.backend.domain.admin.dto.UserResponse; + +import org.springframework.data.domain.Page; + +public class AdminMapper { + + private AdminMapper() {} + + public static UserPageResponse toUserListPageResponse(Page userPage) { + int curPage = userPage.getNumber() + 1; + + return new UserPageResponse( + userPage.getTotalPages(), + curPage, + userPage.getNumberOfElements(), + userPage.getContent()); + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/question/app/QuestionService.java b/backend/src/main/java/io/f1/backend/domain/question/app/QuestionService.java index 277353b1..d759cd99 100644 --- a/backend/src/main/java/io/f1/backend/domain/question/app/QuestionService.java +++ b/backend/src/main/java/io/f1/backend/domain/question/app/QuestionService.java @@ -10,13 +10,18 @@ import io.f1.backend.domain.question.entity.TextQuestion; import io.f1.backend.domain.quiz.entity.Quiz; import io.f1.backend.global.exception.CustomException; +import io.f1.backend.global.exception.errorcode.AuthErrorCode; import io.f1.backend.global.exception.errorcode.QuestionErrorCode; +import io.f1.backend.global.security.enums.Role; +import io.f1.backend.global.util.SecurityUtils; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Objects; + @Service @RequiredArgsConstructor public class QuestionService { @@ -47,10 +52,21 @@ public void updateQuestionContent(Long questionId, String content) { .orElseThrow( () -> new CustomException(QuestionErrorCode.QUESTION_NOT_FOUND)); + verifyUserAuthority(question.getQuiz()); + TextQuestion textQuestion = question.getTextQuestion(); textQuestion.changeContent(content); } + private static void verifyUserAuthority(Quiz quiz) { + if (SecurityUtils.getCurrentUserRole() == Role.ADMIN) { + return; + } + if (!Objects.equals(SecurityUtils.getCurrentUserId(), quiz.getCreator().getId())) { + throw new CustomException(AuthErrorCode.FORBIDDEN); + } + } + @Transactional public void updateQuestionAnswer(Long questionId, String answer) { @@ -62,6 +78,8 @@ public void updateQuestionAnswer(Long questionId, String answer) { .orElseThrow( () -> new CustomException(QuestionErrorCode.QUESTION_NOT_FOUND)); + verifyUserAuthority(question.getQuiz()); + question.changeAnswer(answer); } @@ -74,6 +92,8 @@ public void deleteQuestion(Long questionId) { .orElseThrow( () -> new CustomException(QuestionErrorCode.QUESTION_NOT_FOUND)); + verifyUserAuthority(question.getQuiz()); + questionRepository.delete(question); } diff --git a/backend/src/main/java/io/f1/backend/domain/quiz/app/QuizService.java b/backend/src/main/java/io/f1/backend/domain/quiz/app/QuizService.java index 3042f0a0..a8f7a3fa 100644 --- a/backend/src/main/java/io/f1/backend/domain/quiz/app/QuizService.java +++ b/backend/src/main/java/io/f1/backend/domain/quiz/app/QuizService.java @@ -20,6 +20,9 @@ import io.f1.backend.global.exception.CustomException; import io.f1.backend.global.exception.errorcode.AuthErrorCode; import io.f1.backend.global.exception.errorcode.QuizErrorCode; +import io.f1.backend.global.exception.errorcode.UserErrorCode; +import io.f1.backend.global.security.enums.Role; +import io.f1.backend.global.util.SecurityUtils; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -37,6 +40,7 @@ import java.nio.file.Paths; import java.util.List; import java.util.NoSuchElementException; +import java.util.Objects; import java.util.UUID; @Slf4j @@ -53,7 +57,6 @@ public class QuizService { private final String DEFAULT = "default"; private static final long MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB - // TODO : 시큐리티 구현 이후 삭제해도 되는 의존성 주입 private final UserRepository userRepository; private final QuestionService questionService; private final QuizRepository quizRepository; @@ -67,10 +70,13 @@ public QuizCreateResponse saveQuiz(MultipartFile thumbnailFile, QuizCreateReques thumbnailPath = convertToThumbnailPath(thumbnailFile); } - // TODO : 시큐리티 구현 이후 삭제 (data.sql로 초기 저장해둔 유저 get), 나중엔 현재 로그인한 유저의 아이디를 받아오도록 수정 - User user = userRepository.findById(1L).orElseThrow(RuntimeException::new); + Long creatorId = SecurityUtils.getCurrentUserId(); + User creator = + userRepository + .findById(creatorId) + .orElseThrow(() -> new CustomException(UserErrorCode.USER_NOT_FOUND)); - Quiz quiz = quizCreateRequestToQuiz(request, thumbnailPath, user); + Quiz quiz = quizCreateRequestToQuiz(request, thumbnailPath, creator); Quiz savedQuiz = quizRepository.save(quiz); @@ -126,15 +132,21 @@ public void deleteQuiz(Long quizId) { .findById(quizId) .orElseThrow(() -> new CustomException(QuizErrorCode.QUIZ_NOT_FOUND)); - // TODO : util 메서드에서 사용자 ID 꺼내쓰는 식으로 수정하기 - if (1L != quiz.getCreator().getId()) { - throw new CustomException(AuthErrorCode.FORBIDDEN); - } + verifyUserAuthority(quiz); deleteThumbnailFile(quiz.getThumbnailUrl()); quizRepository.deleteById(quizId); } + private static void verifyUserAuthority(Quiz quiz) { + if (SecurityUtils.getCurrentUserRole() == Role.ADMIN) { + return; + } + if (!Objects.equals(SecurityUtils.getCurrentUserId(), quiz.getCreator().getId())) { + throw new CustomException(AuthErrorCode.FORBIDDEN); + } + } + @Transactional public void updateQuizTitle(Long quizId, String title) { Quiz quiz = @@ -142,6 +154,8 @@ public void updateQuizTitle(Long quizId, String title) { .findById(quizId) .orElseThrow(() -> new CustomException(QuizErrorCode.QUIZ_NOT_FOUND)); + verifyUserAuthority(quiz); + validateTitle(title); quiz.changeTitle(title); } @@ -154,6 +168,8 @@ public void updateQuizDesc(Long quizId, String description) { .findById(quizId) .orElseThrow(() -> new CustomException(QuizErrorCode.QUIZ_NOT_FOUND)); + verifyUserAuthority(quiz); + validateDesc(description); quiz.changeDescription(description); } @@ -166,6 +182,8 @@ public void updateThumbnail(Long quizId, MultipartFile thumbnailFile) { .findById(quizId) .orElseThrow(() -> new CustomException(QuizErrorCode.QUIZ_NOT_FOUND)); + verifyUserAuthority(quiz); + validateImageFile(thumbnailFile); String newThumbnailPath = convertToThumbnailPath(thumbnailFile); @@ -257,8 +275,6 @@ public List getRandomQuestionsWithoutAnswer(Long quizId, Integer round .findById(quizId) .orElseThrow(() -> new NoSuchElementException("존재하지 않는 퀴즈입니다.")); - List randomQuestions = quizRepository.findRandQuestionsByQuizId(quizId, round); - - return randomQuestions; + return quizRepository.findRandQuestionsByQuizId(quizId, round); } } diff --git a/backend/src/main/java/io/f1/backend/domain/quiz/mapper/QuizMapper.java b/backend/src/main/java/io/f1/backend/domain/quiz/mapper/QuizMapper.java index b9dffc0e..25a16242 100644 --- a/backend/src/main/java/io/f1/backend/domain/quiz/mapper/QuizMapper.java +++ b/backend/src/main/java/io/f1/backend/domain/quiz/mapper/QuizMapper.java @@ -18,21 +18,18 @@ public class QuizMapper { - // TODO : 이후 파라미터에서 user 삭제하기 public static Quiz quizCreateRequestToQuiz( - QuizCreateRequest quizCreateRequest, String imgUrl, User user) { + QuizCreateRequest quizCreateRequest, String imgUrl, User creator) { return new Quiz( quizCreateRequest.getTitle(), quizCreateRequest.getDescription(), quizCreateRequest.getQuizType(), imgUrl, - user // TODO : 이후 creator에 들어갈 User은 현재 로그인 중인 유저를 가져오도록 변경 - ); + creator); } public static QuizCreateResponse quizToQuizCreateResponse(Quiz quiz) { - // TODO : creatorId 넣어주는 부분에서 Getter를 안 쓰고, 현재 로그인한 유저의 id를 담는 식으로 바꿔도 될 듯 return new QuizCreateResponse( quiz.getId(), quiz.getTitle(), diff --git a/backend/src/main/java/io/f1/backend/domain/user/dao/UserRepository.java b/backend/src/main/java/io/f1/backend/domain/user/dao/UserRepository.java index d7420e59..0cb50caf 100644 --- a/backend/src/main/java/io/f1/backend/domain/user/dao/UserRepository.java +++ b/backend/src/main/java/io/f1/backend/domain/user/dao/UserRepository.java @@ -1,8 +1,12 @@ package io.f1.backend.domain.user.dao; +import io.f1.backend.domain.admin.dto.UserResponse; import io.f1.backend.domain.user.entity.User; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; import java.util.Optional; @@ -13,4 +17,9 @@ public interface UserRepository extends JpaRepository { Optional findByProviderAndProviderId(String provider, String providerId); Boolean existsUserByNickname(String nickname); + + @Query( + "SELECT new io.f1.backend.domain.admin.dto.UserResponse(u.id, u.nickname, u.lastLogin," + + " u.createdAt)FROM User u ORDER BY u.id") + Page findAllUsersWithPaging(Pageable pageable); } diff --git a/backend/src/main/java/io/f1/backend/global/config/CorsConfig.java b/backend/src/main/java/io/f1/backend/global/config/CorsConfig.java index 3cc2e2aa..37606918 100644 --- a/backend/src/main/java/io/f1/backend/global/config/CorsConfig.java +++ b/backend/src/main/java/io/f1/backend/global/config/CorsConfig.java @@ -8,10 +8,12 @@ @Configuration public class CorsConfig { + @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); + config.addAllowedOrigin("http://localhost:3000"); config.addAllowedOrigin("https://brainrace.duckdns.org"); config.addAllowedHeader("*"); diff --git a/backend/src/main/java/io/f1/backend/global/config/SecurityConfig.java b/backend/src/main/java/io/f1/backend/global/config/SecurityConfig.java index c6b4680c..844acb30 100644 --- a/backend/src/main/java/io/f1/backend/global/config/SecurityConfig.java +++ b/backend/src/main/java/io/f1/backend/global/config/SecurityConfig.java @@ -6,6 +6,7 @@ import io.f1.backend.domain.user.app.handler.CustomAuthenticationEntryPoint; import io.f1.backend.domain.user.app.handler.OAuthSuccessHandler; import io.f1.backend.domain.user.app.handler.UserAndAdminLogoutSuccessHandler; +import io.f1.backend.global.filter.DevTokenAuthFilter; import lombok.RequiredArgsConstructor; @@ -17,6 +18,7 @@ import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration @EnableWebSecurity @@ -34,6 +36,8 @@ public class SecurityConfig { public SecurityFilterChain userFilterChain(HttpSecurity http) throws Exception { http.csrf(AbstractHttpConfigurer::disable) .cors(Customizer.withDefaults()) + .addFilterBefore( + new DevTokenAuthFilter(), UsernamePasswordAuthenticationFilter.class) .exceptionHandling( exception -> exception.authenticationEntryPoint(customAuthenticationEntryPoint)) diff --git a/backend/src/main/java/io/f1/backend/global/filter/DevTokenAuthFilter.java b/backend/src/main/java/io/f1/backend/global/filter/DevTokenAuthFilter.java new file mode 100644 index 00000000..0266a6b1 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/global/filter/DevTokenAuthFilter.java @@ -0,0 +1,76 @@ +package io.f1.backend.global.filter; + +import io.f1.backend.domain.admin.dto.AdminPrincipal; +import io.f1.backend.domain.admin.entity.Admin; +import io.f1.backend.domain.user.dto.UserPrincipal; +import io.f1.backend.domain.user.entity.User; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +@Component +public class DevTokenAuthFilter extends OncePerRequestFilter { + + private static final String DEV_TOKEN = "dev-secret-token-1234"; + private static final String ADMIN_TOKEN = "admin-secret-token-1234"; + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + User fakeUser = + User.builder() + .provider("kakao") + .providerId("dev") + .lastLogin(LocalDateTime.now()) + .build(); + + fakeUser.setId(1L); + fakeUser.updateNickname("user"); + + UserPrincipal principal = new UserPrincipal(fakeUser, Map.of()); + + Admin fakeAdmin = + Admin.builder() + .id(1L) + .username("admin") + .password("admin") + .lastLogin(LocalDateTime.now()) + .build(); + + AdminPrincipal adminPrincipal = new AdminPrincipal(fakeAdmin); + + String authHeader = request.getHeader("Authorization"); + + if (authHeader != null && authHeader.equals("Bearer " + DEV_TOKEN)) { + List authorities = List.of(new SimpleGrantedAuthority("ROLE_USER")); + + Authentication auth = + new UsernamePasswordAuthenticationToken(principal, null, authorities); + SecurityContextHolder.getContext().setAuthentication(auth); + } else if (authHeader != null && authHeader.equals("Bearer " + ADMIN_TOKEN)) { + List authorities = List.of(new SimpleGrantedAuthority("ROLE_ADMIN")); + + Authentication auth = + new UsernamePasswordAuthenticationToken(adminPrincipal, null, authorities); + SecurityContextHolder.getContext().setAuthentication(auth); + } + filterChain.doFilter(request, response); + } +} diff --git a/backend/src/main/java/io/f1/backend/global/util/SecurityUtils.java b/backend/src/main/java/io/f1/backend/global/util/SecurityUtils.java index 96e2b316..194e310a 100644 --- a/backend/src/main/java/io/f1/backend/global/util/SecurityUtils.java +++ b/backend/src/main/java/io/f1/backend/global/util/SecurityUtils.java @@ -5,6 +5,7 @@ import io.f1.backend.domain.user.entity.User; import io.f1.backend.global.exception.CustomException; import io.f1.backend.global.exception.errorcode.AuthErrorCode; +import io.f1.backend.global.security.enums.Role; import jakarta.servlet.http.HttpSession; @@ -43,6 +44,15 @@ public static String getCurrentUserNickname() { return getCurrentUserPrincipal().getUserNickname(); } + public static Role getCurrentUserRole() { + Authentication authentication = getAuthentication(); + if (authentication != null + && authentication.getPrincipal() instanceof UserPrincipal userPrincipal) { + return Role.USER; + } + return Role.ADMIN; + } + public static void logout(HttpSession session) { if (session != null) { session.invalidate(); diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 711788eb..828cd9f7 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -56,10 +56,13 @@ file: default-thumbnail-url: /images/thumbnail/default.png server: + port: 8080 + forward-headers-strategy: native servlet: session: cookie: same-site: None secure: true http-only: true - timeout: 60 \ No newline at end of file + timeout: 60 + diff --git a/backend/src/test/java/io/f1/backend/domain/admin/app/AdminServiceTests.java b/backend/src/test/java/io/f1/backend/domain/admin/app/AdminServiceTests.java new file mode 100644 index 00000000..b3db7eea --- /dev/null +++ b/backend/src/test/java/io/f1/backend/domain/admin/app/AdminServiceTests.java @@ -0,0 +1,52 @@ +package io.f1.backend.domain.admin.app; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.github.database.rider.core.api.dataset.DataSet; + +import io.f1.backend.global.template.BrowserTestTemplate; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.ResultActions; + +@WithMockUser(roles = "ADMIN") +public class AdminServiceTests extends BrowserTestTemplate { + + @Test + @DataSet("datasets/admin/sorted-user.yml") + @DisplayName("가입한 유저가 3명이면 첫 페이지에서 3개의 결과를 반환한다") + void totalUser() throws Exception { + // when + ResultActions result = mockMvc.perform(get("/admin/users")); + + // then + result.andExpectAll( + status().isOk(), + jsonPath("$.totalPages").value(1), + jsonPath("$.currentPage").value(1), + jsonPath("$.totalElements").value(3), + jsonPath("$.users.length()").value(3)); + } + + @Test + @DataSet("datasets/admin/sorted-user.yml") + @DisplayName("유저 목록이 id 순으로 정렬되어 반환된다") + void getUsersSortedByLastLogin() throws Exception { + // when + ResultActions result = mockMvc.perform(get("/admin/users")); + // then + result.andExpectAll( + status().isOk(), + jsonPath("$.totalElements").value(3), + // 가장 최근 로그인한 USER3이 첫 번째 + jsonPath("$.users[0].id").value(1), + // 중간 로그인한 USER2가 두 번째 + jsonPath("$.users[1].id").value(2), + // 가장 오래된 로그인한 USER1이 세 번째 + jsonPath("$.users[2].id").value(3)); + } +} diff --git a/backend/src/test/resources/datasets/admin/sorted-user.yml b/backend/src/test/resources/datasets/admin/sorted-user.yml new file mode 100644 index 00000000..12ca6228 --- /dev/null +++ b/backend/src/test/resources/datasets/admin/sorted-user.yml @@ -0,0 +1,22 @@ + +user_test: + - id: 1 + nickname: "USER1" + provider: "kakao" + provider_id: "kakao1" + last_login: 2025-07-16 09:00:00 # 가장 오래된 로그인 + created_at: 2025-07-01 10:00:00 + + - id: 2 + nickname: "USER2" + provider: "kakao" + provider_id: "kakao2" + last_login: 2025-07-17 12:00:00 # 중간 로그인 + created_at: 2025-07-02 10:00:00 + + - id: 3 + nickname: "USER3" + provider: "kakao" + provider_id: "kakao3" + last_login: 2025-07-18 15:00:00 # 가장 최근 로그인 + created_at: 2025-07-03 10:00:00 \ No newline at end of file