diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..2bb64a0 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "Bash(./gradlew compileKotlin:*)", + "Bash(git mv:*)" + ], + "deny": [], + "ask": [] + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 3e9db74..1483a3f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -29,6 +29,10 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0") implementation("org.springframework.boot:spring-boot-starter-quartz") + implementation("org.springframework.boot:spring-boot-starter-security") + implementation("io.jsonwebtoken:jjwt-api:0.12.3") + runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.3") + runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.3") runtimeOnly("com.mysql:mysql-connector-j") testImplementation("org.mockito:mockito-core:4.11.0") diff --git a/src/main/kotlin/nexters/weski/WeskiApplication.kt b/src/main/kotlin/nexters/weski/WeskiApplication.kt index 80ee88e..ca48889 100644 --- a/src/main/kotlin/nexters/weski/WeskiApplication.kt +++ b/src/main/kotlin/nexters/weski/WeskiApplication.kt @@ -1,11 +1,14 @@ package nexters.weski +import nexters.weski.auth.JwtProperties import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.boot.runApplication import org.springframework.scheduling.annotation.EnableScheduling @SpringBootApplication(scanBasePackages = ["nexters.weski"]) @EnableScheduling +@EnableConfigurationProperties(JwtProperties::class) class WeskiApplication fun main(args: Array) { diff --git a/src/main/kotlin/nexters/weski/auth/AuthController.kt b/src/main/kotlin/nexters/weski/auth/AuthController.kt new file mode 100644 index 0000000..fdbf42a --- /dev/null +++ b/src/main/kotlin/nexters/weski/auth/AuthController.kt @@ -0,0 +1,41 @@ +package nexters.weski.auth + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import nexters.weski.user.ApiResponse +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "인증 API", description = "JWT 토큰 관리 API") +@RestController +@RequestMapping("/api/auth") +class AuthController( + private val authService: AuthService +) { + + @Operation(summary = "토큰 갱신", description = "Refresh Token을 사용하여 새로운 Access Token을 발급받습니다") + @PostMapping("/refresh") + fun refreshToken( + @Valid @RequestBody request: RefreshTokenRequest + ): ResponseEntity> { + val response = authService.refreshToken(request.refreshToken) + return ResponseEntity.ok( + ApiResponse.success("토큰이 성공적으로 갱신되었습니다", response) + ) + } + + @Operation(summary = "로그아웃", description = "Refresh Token을 삭제하여 로그아웃합니다") + @PostMapping("/logout") + fun logout( + @Valid @RequestBody request: RefreshTokenRequest + ): ResponseEntity> { + authService.logout(request.refreshToken) + return ResponseEntity.ok( + ApiResponse.success("로그아웃이 성공적으로 처리되었습니다") + ) + } +} diff --git a/src/main/kotlin/nexters/weski/auth/AuthDto.kt b/src/main/kotlin/nexters/weski/auth/AuthDto.kt new file mode 100644 index 0000000..af9e080 --- /dev/null +++ b/src/main/kotlin/nexters/weski/auth/AuthDto.kt @@ -0,0 +1,20 @@ +package nexters.weski.auth + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank + +@Schema(description = "토큰 갱신 요청 DTO") +data class RefreshTokenRequest( + @field:NotBlank(message = "Refresh Token은 필수입니다") + @Schema(description = "Refresh Token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + val refreshToken: String +) + +@Schema(description = "토큰 응답 DTO") +data class TokenResponse( + @Schema(description = "액세스 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + val accessToken: String, + + @Schema(description = "리프레시 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + val refreshToken: String +) diff --git a/src/main/kotlin/nexters/weski/auth/AuthService.kt b/src/main/kotlin/nexters/weski/auth/AuthService.kt new file mode 100644 index 0000000..37418d7 --- /dev/null +++ b/src/main/kotlin/nexters/weski/auth/AuthService.kt @@ -0,0 +1,64 @@ +package nexters.weski.auth + +import nexters.weski.user.RefreshToken +import nexters.weski.user.RefreshTokenRepository +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +@Transactional +class AuthService( + private val jwtTokenProvider: JwtTokenProvider, + private val refreshTokenRepository: RefreshTokenRepository +) { + + /** + * Refresh Token으로 새로운 Access Token 발급 + */ + fun refreshToken(refreshToken: String): TokenResponse { + // Refresh Token 유효성 검증 + if (!jwtTokenProvider.validateToken(refreshToken)) { + throw IllegalArgumentException("유효하지 않은 Refresh Token입니다") + } + + // DB에서 Refresh Token 조회 + val storedToken = refreshTokenRepository.findByToken(refreshToken) + ?: throw IllegalArgumentException("등록되지 않은 Refresh Token입니다") + + // 만료 확인 + if (storedToken.expiresAt.isBefore(LocalDateTime.now())) { + refreshTokenRepository.delete(storedToken) + throw IllegalArgumentException("만료된 Refresh Token입니다") + } + + // 새로운 토큰 발급 + val userId = storedToken.userId + val newAccessToken = jwtTokenProvider.generateAccessToken(userId) + val newRefreshToken = jwtTokenProvider.generateRefreshToken(userId) + + // 기존 Refresh Token 삭제 후 새로 저장 + refreshTokenRepository.delete(storedToken) + val newTokenEntity = RefreshToken( + userId = userId, + token = newRefreshToken, + expiresAt = LocalDateTime.now().plusDays(7) + ) + refreshTokenRepository.save(newTokenEntity) + + return TokenResponse( + accessToken = newAccessToken, + refreshToken = newRefreshToken + ) + } + + /** + * 로그아웃 - Refresh Token 삭제 + */ + fun logout(refreshToken: String) { + val storedToken = refreshTokenRepository.findByToken(refreshToken) + storedToken?.let { + refreshTokenRepository.delete(it) + } + } +} diff --git a/src/main/kotlin/nexters/weski/auth/JwtAuthenticationFilter.kt b/src/main/kotlin/nexters/weski/auth/JwtAuthenticationFilter.kt new file mode 100644 index 0000000..f3f4807 --- /dev/null +++ b/src/main/kotlin/nexters/weski/auth/JwtAuthenticationFilter.kt @@ -0,0 +1,52 @@ +package nexters.weski.auth + +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken +import org.springframework.security.core.context.SecurityContextHolder +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter + +@Component +class JwtAuthenticationFilter( + private val jwtTokenProvider: JwtTokenProvider +) : OncePerRequestFilter() { + + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain + ) { + try { + val jwt = getJwtFromRequest(request) + + if (jwt != null && jwtTokenProvider.validateToken(jwt)) { + val userId = jwtTokenProvider.getUserIdFromToken(jwt) + + val authentication = UsernamePasswordAuthenticationToken( + userId, + null, + emptyList() + ) + authentication.details = WebAuthenticationDetailsSource().buildDetails(request) + + SecurityContextHolder.getContext().authentication = authentication + } + } catch (ex: Exception) { + logger.error("Could not set user authentication in security context", ex) + } + + filterChain.doFilter(request, response) + } + + private fun getJwtFromRequest(request: HttpServletRequest): String? { + val bearerToken = request.getHeader("Authorization") + return if (bearerToken != null && bearerToken.startsWith("Bearer ")) { + bearerToken.substring(7) + } else { + null + } + } +} diff --git a/src/main/kotlin/nexters/weski/auth/JwtProperties.kt b/src/main/kotlin/nexters/weski/auth/JwtProperties.kt new file mode 100644 index 0000000..9788b03 --- /dev/null +++ b/src/main/kotlin/nexters/weski/auth/JwtProperties.kt @@ -0,0 +1,10 @@ +package nexters.weski.auth + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "jwt") +data class JwtProperties( + val secret: String, + val accessTokenExpiration: Long, + val refreshTokenExpiration: Long +) diff --git a/src/main/kotlin/nexters/weski/auth/JwtTokenProvider.kt b/src/main/kotlin/nexters/weski/auth/JwtTokenProvider.kt new file mode 100644 index 0000000..a31d868 --- /dev/null +++ b/src/main/kotlin/nexters/weski/auth/JwtTokenProvider.kt @@ -0,0 +1,76 @@ +package nexters.weski.auth + +import io.jsonwebtoken.Claims +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.security.Keys +import org.springframework.stereotype.Component +import java.util.Date +import javax.crypto.SecretKey + +@Component +class JwtTokenProvider( + private val jwtProperties: JwtProperties +) { + private val secretKey: SecretKey = Keys.hmacShaKeyFor(jwtProperties.secret.toByteArray()) + + /** + * Access Token 생성 + */ + fun generateAccessToken(userId: Long): String { + val now = Date() + val expiryDate = Date(now.time + jwtProperties.accessTokenExpiration) + + return Jwts.builder() + .subject(userId.toString()) + .issuedAt(now) + .expiration(expiryDate) + .signWith(secretKey) + .compact() + } + + /** + * Refresh Token 생성 + */ + fun generateRefreshToken(userId: Long): String { + val now = Date() + val expiryDate = Date(now.time + jwtProperties.refreshTokenExpiration) + + return Jwts.builder() + .subject(userId.toString()) + .issuedAt(now) + .expiration(expiryDate) + .signWith(secretKey) + .compact() + } + + /** + * Token에서 사용자 ID 추출 + */ + fun getUserIdFromToken(token: String): Long { + val claims = parseToken(token) + return claims.subject.toLong() + } + + /** + * Token 유효성 검증 + */ + fun validateToken(token: String): Boolean { + return try { + parseToken(token) + true + } catch (e: Exception) { + false + } + } + + /** + * Token 파싱 + */ + private fun parseToken(token: String): Claims { + return Jwts.parser() + .verifyWith(secretKey) + .build() + .parseSignedClaims(token) + .payload + } +} diff --git a/src/main/kotlin/nexters/weski/auth/SecurityConfig.kt b/src/main/kotlin/nexters/weski/auth/SecurityConfig.kt new file mode 100644 index 0000000..69ba404 --- /dev/null +++ b/src/main/kotlin/nexters/weski/auth/SecurityConfig.kt @@ -0,0 +1,39 @@ +package nexters.weski.auth + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity +import org.springframework.security.config.http.SessionCreationPolicy +import org.springframework.security.web.SecurityFilterChain +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter + +@Configuration +@EnableWebSecurity +class SecurityConfig( + private val jwtAuthenticationFilter: JwtAuthenticationFilter +) { + + @Bean + fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + http + .csrf { it.disable() } + .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } + .authorizeHttpRequests { authorize -> + authorize + .requestMatchers( + "/api/users/auth/**", // 로그인/회원가입 + "/api/auth/**", // 토큰 갱신/로그아웃 + "/v3/api-docs/**", // Swagger + "/swagger-ui/**", // Swagger UI + "/swagger-ui.html", + "/actuator/**" // Actuator + ).permitAll() + // 나머지는 인증 필요 + .anyRequest().authenticated() + } + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter::class.java) + + return http.build() + } +} diff --git a/src/main/kotlin/nexters/weski/user/AdminUserController.kt b/src/main/kotlin/nexters/weski/user/AdminUserController.kt new file mode 100644 index 0000000..afe4900 --- /dev/null +++ b/src/main/kotlin/nexters/weski/user/AdminUserController.kt @@ -0,0 +1,189 @@ +package nexters.weski.user + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.data.domain.Page +import org.springframework.data.domain.PageRequest +import org.springframework.data.domain.Pageable +import org.springframework.data.domain.Sort +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.CrossOrigin +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import java.time.LocalDateTime + +@Tag(name = "Admin 사용자 관리 API", description = "관리자용 사용자 CRUD 및 관리 작업") +@RestController +@RequestMapping("/api/admin/users") +@CrossOrigin(origins = ["http://localhost:3000", "http://localhost:5173"]) // React 개발 서버용 +class AdminUserController( + private val adminUserService: AdminUserService, +) { + @Operation(summary = "모든 사용자 목록 조회 (페이징)", description = "관리자가 볼 수 있는 모든 사용자 목록을 페이징으로 조회합니다") + @GetMapping + fun getAllUsers( + @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") + @RequestParam(defaultValue = "0") page: Int, + @Parameter(description = "페이지 크기", example = "20") + @RequestParam(defaultValue = "20") size: Int, + @Parameter(description = "정렬 기준", example = "createdAt") + @RequestParam(defaultValue = "createdAt") sort: String, + @Parameter(description = "정렬 방향", example = "desc") + @RequestParam(defaultValue = "desc") direction: String, + ): ResponseEntity>> { + val sortDirection = if (direction.lowercase() == "desc") Sort.Direction.DESC else Sort.Direction.ASC + val pageable: Pageable = PageRequest.of(page, size, Sort.by(sortDirection, sort)) + val users = adminUserService.getAllUsers(pageable) + return ResponseEntity.ok( + ApiResponse.success("사용자 목록을 성공적으로 조회했습니다", users) + ) + } + + @Operation(summary = "상태별 사용자 조회", description = "특정 계정 상태의 사용자들을 조회합니다") + @GetMapping("/status/{status}") + fun getUsersByStatus( + @Parameter(description = "계정 상태", example = "ACTIVE") + @PathVariable status: AccountStatus, + ): ResponseEntity>> { + val users = adminUserService.getUsersByStatus(status) + return ResponseEntity.ok( + ApiResponse.success("${status.name} 상태 사용자 목록을 성공적으로 조회했습니다", users) + ) + } + + @Operation(summary = "특정 사용자 상세 조회", description = "관리자용 상세한 사용자 정보를 조회합니다") + @GetMapping("/{userId}") + fun getUserDetail( + @Parameter(description = "사용자 ID", example = "1") + @PathVariable userId: Long, + ): ResponseEntity> { + val userDetail = adminUserService.getUserDetail(userId) + return ResponseEntity.ok( + ApiResponse.success("사용자 상세 정보를 성공적으로 조회했습니다", userDetail) + ) + } + + @Operation(summary = "사용자 상태 변경", description = "사용자의 계정 상태를 변경합니다") + @PutMapping("/{userId}/status") + fun updateUserStatus( + @Parameter(description = "사용자 ID", example = "1") + @PathVariable userId: Long, + @Valid @RequestBody request: UpdateUserStatusRequest, + ): ResponseEntity> { + val updatedUser = adminUserService.updateUserStatus(userId, request.status) + return ResponseEntity.ok( + ApiResponse.success("사용자 상태가 성공적으로 변경되었습니다", updatedUser) + ) + } + + @Operation(summary = "사용자 강제 탈퇴", description = "관리자가 사용자를 강제로 탈퇴시킵니다") + @DeleteMapping("/{userId}/force") + fun forceDeleteUser( + @Parameter(description = "사용자 ID", example = "1") + @PathVariable userId: Long, + @RequestBody(required = false) request: ForceDeleteUserRequest?, + ): ResponseEntity> { + adminUserService.forceDeleteUser(userId, request?.reason ?: "관리자에 의한 강제 탈퇴") + return ResponseEntity.ok( + ApiResponse.success("사용자가 성공적으로 강제 탈퇴 처리되었습니다") + ) + } + + @Operation(summary = "탈퇴 사용자 복구", description = "탈퇴한 사용자를 활성 상태로 복구합니다") + @PostMapping("/{userId}/restore") + fun restoreUser( + @Parameter(description = "사용자 ID", example = "1") + @PathVariable userId: Long, + ): ResponseEntity> { + val restoredUser = adminUserService.restoreUser(userId) + return ResponseEntity.ok( + ApiResponse.success("사용자가 성공적으로 복구되었습니다", restoredUser) + ) + } + + @Operation(summary = "OAuth 계정 강제 해제", description = "사용자의 특정 OAuth 계정을 강제로 해제합니다") + @DeleteMapping("/{userId}/oauth/{provider}") + fun forceUnlinkOAuthAccount( + @Parameter(description = "사용자 ID", example = "1") + @PathVariable userId: Long, + @Parameter(description = "OAuth 제공자", example = "KAKAO") + @PathVariable provider: OAuthProvider, + ): ResponseEntity> { + adminUserService.forceUnlinkOAuthAccount(userId, provider) + return ResponseEntity.ok( + ApiResponse.success("${provider.name} 계정이 성공적으로 해제되었습니다") + ) + } + + @Operation(summary = "디바이스 계정 강제 해제", description = "사용자의 특정 디바이스 계정을 강제로 해제합니다") + @DeleteMapping("/{userId}/device/{deviceId}") + fun forceUnlinkDeviceAccount( + @Parameter(description = "사용자 ID", example = "1") + @PathVariable userId: Long, + @Parameter(description = "디바이스 ID", example = "device_12345") + @PathVariable deviceId: String, + ): ResponseEntity> { + adminUserService.forceUnlinkDeviceAccount(userId, deviceId) + return ResponseEntity.ok( + ApiResponse.success("디바이스 계정이 성공적으로 해제되었습니다") + ) + } + + @Operation(summary = "사용자 통계 조회", description = "전체 사용자에 대한 통계 정보를 조회합니다") + @GetMapping("/statistics") + fun getUserStatistics(): ResponseEntity> { + val statistics = adminUserService.getUserStatistics() + return ResponseEntity.ok( + ApiResponse.success("사용자 통계 정보를 성공적으로 조회했습니다", statistics) + ) + } + + @Operation(summary = "닉네임으로 사용자 검색", description = "닉네임으로 사용자를 검색합니다") + @GetMapping("/search/nickname") + fun searchUsersByNickname( + @Parameter(description = "검색할 닉네임", example = "스키") + @RequestParam nickname: String, + ): ResponseEntity>> { + val users = adminUserService.searchUsersByNickname(nickname) + return ResponseEntity.ok( + ApiResponse.success("닉네임 검색 결과를 성공적으로 조회했습니다", users) + ) + } + + @Operation(summary = "이메일로 사용자 검색", description = "이메일로 사용자를 검색합니다") + @GetMapping("/search/email") + fun searchUsersByEmail( + @Parameter(description = "검색할 이메일", example = "user@example.com") + @RequestParam email: String, + ): ResponseEntity>> { + val users = adminUserService.searchUsersByEmail(email) + return ResponseEntity.ok( + ApiResponse.success("이메일 검색 결과를 성공적으로 조회했습니다", users) + ) + } + + @Operation(summary = "기간별 가입자 조회", description = "특정 기간 내에 가입한 사용자들을 조회합니다") + @GetMapping("/search/period") + fun getUsersRegisteredBetween( + @Parameter(description = "시작 날짜 (ISO 형식)", example = "2024-01-01T00:00:00") + @RequestParam startDate: String, + @Parameter(description = "종료 날짜 (ISO 형식)", example = "2024-12-31T23:59:59") + @RequestParam endDate: String, + ): ResponseEntity>> { + val startDateTime = LocalDateTime.parse(startDate) + val endDateTime = LocalDateTime.parse(endDate) + val users = adminUserService.getUsersRegisteredBetween(startDateTime, endDateTime) + return ResponseEntity.ok( + ApiResponse.success("기간별 가입자 조회 결과를 성공적으로 조회했습니다", users) + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/nexters/weski/user/AdminUserDto.kt b/src/main/kotlin/nexters/weski/user/AdminUserDto.kt new file mode 100644 index 0000000..fa44105 --- /dev/null +++ b/src/main/kotlin/nexters/weski/user/AdminUserDto.kt @@ -0,0 +1,257 @@ +package nexters.weski.user + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotNull + +/** + * Admin용 사용자 상태 변경 요청 DTO + */ +@Schema(description = "사용자 상태 변경 요청 DTO") +data class UpdateUserStatusRequest( + @field:NotNull(message = "계정 상태는 필수입니다") + @Schema(description = "변경할 계정 상태", example = "INACTIVE") + val status: AccountStatus, +) + +/** + * Admin용 사용자 강제 탈퇴 요청 DTO + */ +@Schema(description = "사용자 강제 탈퇴 요청 DTO") +data class ForceDeleteUserRequest( + @Schema(description = "탈퇴 사유", example = "약관 위반") + val reason: String? = null, +) + +/** + * Admin용 사용자 목록 응답 DTO + */ +@Schema(description = "Admin용 사용자 목록 응답 DTO") +data class AdminUserResponse( + @Schema(description = "사용자 ID", example = "1") + val userId: Long, + + @Schema(description = "닉네임", example = "스키러버") + val nickname: String, + + @Schema(description = "프로필 이미지 URL", example = "https://example.com/profile.jpg") + val profileImageUrl: String?, + + @Schema(description = "계정 상태", example = "ACTIVE") + val status: String, + + @Schema(description = "연동된 OAuth 계정 수", example = "2") + val oauthAccountCount: Int, + + @Schema(description = "등록된 디바이스 수", example = "3") + val deviceAccountCount: Int, + + @Schema(description = "생성일시", example = "2024-01-01T10:00:00") + val createdAt: String, + + @Schema(description = "수정일시", example = "2024-01-01T10:00:00") + val updatedAt: String, +) { + companion object { + fun fromEntity( + user: User, + oauthAccounts: List, + deviceAccounts: List, + ): AdminUserResponse = + AdminUserResponse( + userId = user.id!!, + nickname = user.nickname, + profileImageUrl = user.profileImageUrl, + status = user.status.name, + oauthAccountCount = oauthAccounts.size, + deviceAccountCount = deviceAccounts.size, + createdAt = user.createdAt.toString(), + updatedAt = user.updatedAt.toString() + ) + } +} + +/** + * Admin용 사용자 상세 응답 DTO + */ +@Schema(description = "Admin용 사용자 상세 응답 DTO") +data class AdminUserDetailResponse( + @Schema(description = "사용자 ID", example = "1") + val userId: Long, + + @Schema(description = "닉네임", example = "스키러버") + val nickname: String, + + @Schema(description = "프로필 이미지 URL", example = "https://example.com/profile.jpg") + val profileImageUrl: String?, + + @Schema(description = "계정 상태", example = "ACTIVE") + val status: String, + + @Schema(description = "연동된 OAuth 계정 목록") + val oauthAccounts: List, + + @Schema(description = "등록된 디바이스 목록") + val deviceAccounts: List, + + @Schema(description = "생성일시", example = "2024-01-01T10:00:00") + val createdAt: String, + + @Schema(description = "수정일시", example = "2024-01-01T10:00:00") + val updatedAt: String, +) { + companion object { + fun fromEntity( + user: User, + oauthAccounts: List, + deviceAccounts: List, + ): AdminUserDetailResponse = + AdminUserDetailResponse( + userId = user.id!!, + nickname = user.nickname, + profileImageUrl = user.profileImageUrl, + status = user.status.name, + oauthAccounts = oauthAccounts.map { AdminOAuthAccountDto.fromEntity(it) }, + deviceAccounts = deviceAccounts.map { AdminDeviceAccountDto.fromEntity(it) }, + createdAt = user.createdAt.toString(), + updatedAt = user.updatedAt.toString() + ) + } +} + +/** + * Admin용 OAuth 계정 정보 DTO (더 상세한 정보 포함) + */ +@Schema(description = "Admin용 OAuth 계정 정보 DTO") +data class AdminOAuthAccountDto( + @Schema(description = "OAuth 계정 ID", example = "1") + val id: Long, + + @Schema(description = "OAuth 제공자", example = "KAKAO") + val provider: String, + + @Schema(description = "제공자 사용자 ID", example = "kakao_123456789") + val providerId: String, + + @Schema(description = "이메일", example = "user@example.com") + val email: String?, + + @Schema(description = "Apple AuthCode 보유 여부", example = "true") + val hasAppleAuthCode: Boolean, + + @Schema(description = "연동일시", example = "2024-01-01T10:00:00") + val createdAt: String, + + @Schema(description = "수정일시", example = "2024-01-01T10:00:00") + val updatedAt: String, +) { + companion object { + fun fromEntity(oauthAccount: OAuthAccount): AdminOAuthAccountDto = + AdminOAuthAccountDto( + id = oauthAccount.id!!, + provider = oauthAccount.provider.name, + providerId = oauthAccount.providerId, + email = oauthAccount.email, + hasAppleAuthCode = !oauthAccount.appleAuthCode.isNullOrBlank(), + createdAt = oauthAccount.createdAt.toString(), + updatedAt = oauthAccount.updatedAt.toString() + ) + } +} + +/** + * Admin용 디바이스 계정 정보 DTO + */ +@Schema(description = "Admin용 디바이스 계정 정보 DTO") +data class AdminDeviceAccountDto( + @Schema(description = "디바이스 계정 ID", example = "1") + val id: Long, + + @Schema(description = "디바이스 ID", example = "device_12345") + val deviceId: String, + + @Schema(description = "디바이스 타입", example = "IOS") + val deviceType: String, + + @Schema(description = "등록일시", example = "2024-01-01T10:00:00") + val createdAt: String, + + @Schema(description = "수정일시", example = "2024-01-01T10:00:00") + val updatedAt: String, +) { + companion object { + fun fromEntity(deviceAccount: DeviceAccount): AdminDeviceAccountDto = + AdminDeviceAccountDto( + id = deviceAccount.id!!, + deviceId = deviceAccount.deviceId, + deviceType = deviceAccount.deviceType.name, + createdAt = deviceAccount.createdAt.toString(), + updatedAt = deviceAccount.updatedAt.toString() + ) + } +} + +/** + * Admin용 사용자 통계 응답 DTO + */ +@Schema(description = "사용자 통계 정보 DTO") +data class AdminUserStatisticsResponse( + @Schema(description = "전체 사용자 수", example = "1000") + val totalUsers: Long, + + @Schema(description = "활성 사용자 수", example = "800") + val activeUsers: Long, + + @Schema(description = "비활성 사용자 수", example = "150") + val inactiveUsers: Long, + + @Schema(description = "탈퇴 사용자 수", example = "50") + val deletedUsers: Long, + + @Schema(description = "전체 OAuth 계정 수", example = "1200") + val totalOAuthAccounts: Long, + + @Schema(description = "카카오 계정 수", example = "700") + val kakaoAccounts: Long, + + @Schema(description = "애플 계정 수", example = "500") + val appleAccounts: Long, + + @Schema(description = "전체 디바이스 계정 수", example = "1500") + val totalDeviceAccounts: Long, + + @Schema(description = "iOS 디바이스 수", example = "600") + val iosDevices: Long, + + @Schema(description = "Android 디바이스 수", example = "800") + val androidDevices: Long, + + @Schema(description = "Web 디바이스 수", example = "100") + val webDevices: Long, +) + +/** + * Admin용 사용자 검색 조건 DTO + */ +@Schema(description = "사용자 검색 조건 DTO") +data class UserSearchRequest( + @Schema(description = "닉네임 검색어", example = "스키") + val nickname: String? = null, + + @Schema(description = "이메일 검색어", example = "user@example.com") + val email: String? = null, + + @Schema(description = "계정 상태 필터", example = "ACTIVE") + val status: AccountStatus? = null, + + @Schema(description = "OAuth 제공자 필터", example = "KAKAO") + val oauthProvider: OAuthProvider? = null, + + @Schema(description = "디바이스 타입 필터", example = "IOS") + val deviceType: DeviceType? = null, + + @Schema(description = "검색 시작일", example = "2024-01-01T00:00:00") + val startDate: String? = null, + + @Schema(description = "검색 종료일", example = "2024-12-31T23:59:59") + val endDate: String? = null, +) \ No newline at end of file diff --git a/src/main/kotlin/nexters/weski/user/AdminUserService.kt b/src/main/kotlin/nexters/weski/user/AdminUserService.kt new file mode 100644 index 0000000..4c729b3 --- /dev/null +++ b/src/main/kotlin/nexters/weski/user/AdminUserService.kt @@ -0,0 +1,214 @@ +package nexters.weski.user + +import org.springframework.data.domain.Page +import org.springframework.data.domain.Pageable +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional + +@Service +@Transactional +class AdminUserService( + private val userRepository: UserRepository, + private val oauthAccountRepository: OAuthAccountRepository, + private val deviceAccountRepository: DeviceAccountRepository, +) { + /** + * 모든 사용자 조회 (관리자용 - 페이징) + */ + @Transactional(readOnly = true) + fun getAllUsers(pageable: Pageable): Page { + return userRepository.findAll(pageable) + .map { user -> + val oauthAccounts = oauthAccountRepository.findByUserId(user.id!!) + val deviceAccounts = deviceAccountRepository.findByUserId(user.id!!) + AdminUserResponse.fromEntity(user, oauthAccounts, deviceAccounts) + } + } + + /** + * 특정 상태의 사용자들 조회 + */ + @Transactional(readOnly = true) + fun getUsersByStatus(status: AccountStatus): List { + return userRepository.findByStatus(status) + .map { user -> + val oauthAccounts = oauthAccountRepository.findByUserId(user.id!!) + val deviceAccounts = deviceAccountRepository.findByUserId(user.id!!) + AdminUserResponse.fromEntity(user, oauthAccounts, deviceAccounts) + } + } + + /** + * 특정 사용자 상세 조회 (관리자용) + */ + @Transactional(readOnly = true) + fun getUserDetail(userId: Long): AdminUserDetailResponse { + val user = userRepository.findById(userId) + .orElseThrow { IllegalArgumentException("사용자를 찾을 수 없습니다: $userId") } + + val oauthAccounts = oauthAccountRepository.findByUserId(userId) + val deviceAccounts = deviceAccountRepository.findByUserId(userId) + + return AdminUserDetailResponse.fromEntity(user, oauthAccounts, deviceAccounts) + } + + /** + * 사용자 상태 변경 (관리자용) + */ + fun updateUserStatus(userId: Long, status: AccountStatus): AdminUserResponse { + val user = userRepository.findById(userId) + .orElseThrow { IllegalArgumentException("사용자를 찾을 수 없습니다: $userId") } + + user.status = status + val updatedUser = userRepository.save(user) + + val oauthAccounts = oauthAccountRepository.findByUserId(userId) + val deviceAccounts = deviceAccountRepository.findByUserId(userId) + + return AdminUserResponse.fromEntity(updatedUser, oauthAccounts, deviceAccounts) + } + + /** + * 사용자 강제 탈퇴 (관리자용) + */ + fun forceDeleteUser(userId: Long, reason: String) { + val user = userRepository.findById(userId) + .orElseThrow { IllegalArgumentException("사용자를 찾을 수 없습니다: $userId") } + + if (user.status == AccountStatus.DELETED) { + throw IllegalStateException("이미 탈퇴한 사용자입니다") + } + + // TODO: 탈퇴 사유 로깅 기능 추가 고려 - reason: $reason + user.status = AccountStatus.DELETED + userRepository.save(user) + } + + /** + * 탈퇴한 사용자 복구 (관리자용) + */ + fun restoreUser(userId: Long): AdminUserResponse { + val user = userRepository.findById(userId) + .orElseThrow { IllegalArgumentException("사용자를 찾을 수 없습니다: $userId") } + + if (user.status != AccountStatus.DELETED) { + throw IllegalStateException("탈퇴하지 않은 사용자입니다") + } + + user.status = AccountStatus.ACTIVE + val restoredUser = userRepository.save(user) + + val oauthAccounts = oauthAccountRepository.findByUserId(userId) + val deviceAccounts = deviceAccountRepository.findByUserId(userId) + + return AdminUserResponse.fromEntity(restoredUser, oauthAccounts, deviceAccounts) + } + + /** + * 특정 사용자의 OAuth 계정 강제 해제 (관리자용) + */ + fun forceUnlinkOAuthAccount(userId: Long, provider: OAuthProvider) { + userRepository.findById(userId) + .orElseThrow { IllegalArgumentException("사용자를 찾을 수 없습니다: $userId") } + + val oauthAccount = oauthAccountRepository.findByUserIdAndProvider(userId, provider) + ?: throw IllegalArgumentException("연동된 ${provider.name} 계정을 찾을 수 없습니다") + + oauthAccountRepository.delete(oauthAccount) + } + + /** + * 특정 사용자의 디바이스 계정 강제 해제 (관리자용) + */ + fun forceUnlinkDeviceAccount(userId: Long, deviceId: String) { + userRepository.findById(userId) + .orElseThrow { IllegalArgumentException("사용자를 찾을 수 없습니다: $userId") } + + val deviceAccount = deviceAccountRepository.findByDeviceId(deviceId) + ?: throw IllegalArgumentException("디바이스 계정을 찾을 수 없습니다: $deviceId") + + if (deviceAccount.user.id != userId) { + throw IllegalArgumentException("해당 사용자의 디바이스가 아닙니다") + } + + deviceAccountRepository.delete(deviceAccount) + } + + /** + * 사용자 통계 정보 조회 (관리자용) + */ + @Transactional(readOnly = true) + fun getUserStatistics(): AdminUserStatisticsResponse { + val totalUsers = userRepository.count() + val activeUsers = userRepository.countByStatus(AccountStatus.ACTIVE) + val inactiveUsers = userRepository.countByStatus(AccountStatus.INACTIVE) + val deletedUsers = userRepository.countByStatus(AccountStatus.DELETED) + + val totalOAuthAccounts = oauthAccountRepository.count() + val kakaoAccounts = oauthAccountRepository.countByProvider(OAuthProvider.KAKAO) + val appleAccounts = oauthAccountRepository.countByProvider(OAuthProvider.APPLE) + + val totalDeviceAccounts = deviceAccountRepository.count() + val iosDevices = deviceAccountRepository.countByDeviceType(DeviceType.IOS) + val androidDevices = deviceAccountRepository.countByDeviceType(DeviceType.ANDROID) + val webDevices = deviceAccountRepository.countByDeviceType(DeviceType.WEB) + + return AdminUserStatisticsResponse( + totalUsers = totalUsers, + activeUsers = activeUsers, + inactiveUsers = inactiveUsers, + deletedUsers = deletedUsers, + totalOAuthAccounts = totalOAuthAccounts, + kakaoAccounts = kakaoAccounts, + appleAccounts = appleAccounts, + totalDeviceAccounts = totalDeviceAccounts, + iosDevices = iosDevices, + androidDevices = androidDevices, + webDevices = webDevices + ) + } + + /** + * 닉네임으로 사용자 검색 (관리자용) + */ + @Transactional(readOnly = true) + fun searchUsersByNickname(nickname: String): List { + return userRepository.findByNicknameContainingIgnoreCase(nickname) + .map { user -> + val oauthAccounts = oauthAccountRepository.findByUserId(user.id!!) + val deviceAccounts = deviceAccountRepository.findByUserId(user.id!!) + AdminUserResponse.fromEntity(user, oauthAccounts, deviceAccounts) + } + } + + /** + * 이메일로 사용자 검색 (관리자용) + */ + @Transactional(readOnly = true) + fun searchUsersByEmail(email: String): List { + return oauthAccountRepository.findByEmail(email) + .map { oauthAccount -> + val user = oauthAccount.user + val allOAuthAccounts = oauthAccountRepository.findByUserId(user.id!!) + val deviceAccounts = deviceAccountRepository.findByUserId(user.id!!) + AdminUserResponse.fromEntity(user, allOAuthAccounts, deviceAccounts) + } + .distinctBy { it.userId } // 중복 제거 + } + + /** + * 특정 기간 내 가입한 사용자 조회 (관리자용) + */ + @Transactional(readOnly = true) + fun getUsersRegisteredBetween( + startDate: java.time.LocalDateTime, + endDate: java.time.LocalDateTime + ): List { + return userRepository.findByCreatedAtBetween(startDate, endDate) + .map { user -> + val oauthAccounts = oauthAccountRepository.findByUserId(user.id!!) + val deviceAccounts = deviceAccountRepository.findByUserId(user.id!!) + AdminUserResponse.fromEntity(user, oauthAccounts, deviceAccounts) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/nexters/weski/user/DeviceAccount.kt b/src/main/kotlin/nexters/weski/user/DeviceAccount.kt new file mode 100644 index 0000000..fd9ce51 --- /dev/null +++ b/src/main/kotlin/nexters/weski/user/DeviceAccount.kt @@ -0,0 +1,41 @@ +package nexters.weski.user + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.FetchType +import jakarta.persistence.ForeignKey +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import nexters.weski.common.BaseEntity + +@Entity +@Table(name = "device_accounts") +data class DeviceAccount( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false, foreignKey = ForeignKey(name = "fk_device_user")) + val user: User, + + @Column(nullable = false, unique = true, length = 100, name = "device_id") + val deviceId: String, + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20, name = "device_type") + val deviceType: DeviceType + +) : BaseEntity() + +enum class DeviceType { + IOS, + ANDROID, + WEB +} diff --git a/src/main/kotlin/nexters/weski/user/DeviceAccountRepository.kt b/src/main/kotlin/nexters/weski/user/DeviceAccountRepository.kt new file mode 100644 index 0000000..9e9b874 --- /dev/null +++ b/src/main/kotlin/nexters/weski/user/DeviceAccountRepository.kt @@ -0,0 +1,34 @@ +package nexters.weski.user + +import org.springframework.data.jpa.repository.JpaRepository + + +interface DeviceAccountRepository : JpaRepository { + + // deviceId로 기존 사용자 찾기 (계정 통합 로직) + fun findByDeviceId(deviceId: String): DeviceAccount? + + // 사용자의 모든 디바이스 조회 + fun findByUserId(userId: Long): List + + // 특정 사용자의 특정 디바이스 확인 + fun existsByUserIdAndDeviceId(userId: Long, deviceId: String): Boolean + + // 디바이스가 이미 등록되어 있는지 확인 + fun existsByDeviceId(deviceId: String): Boolean + + // 디바이스 ID로 삭제 (로그아웃 시) + fun deleteByDeviceId(deviceId: String): Long + + // 사용자의 특정 타입 디바이스들 조회 + fun findByUserIdAndDeviceType(userId: Long, deviceType: DeviceType): List + + // 디바이스 타입별 수 통계 + fun countByDeviceType(deviceType: DeviceType): Long + + // 특정 사용자의 모든 디바이스 삭제 + fun deleteByUserId(userId: Long) + + // 특정 디바이스 타입의 모든 계정 조회 (관리자용) + fun findByDeviceType(deviceType: DeviceType): List +} diff --git a/src/main/kotlin/nexters/weski/user/OAuthAccount.kt b/src/main/kotlin/nexters/weski/user/OAuthAccount.kt new file mode 100644 index 0000000..af0e20e --- /dev/null +++ b/src/main/kotlin/nexters/weski/user/OAuthAccount.kt @@ -0,0 +1,46 @@ +package nexters.weski.user + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.FetchType +import jakarta.persistence.ForeignKey +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.JoinColumn +import jakarta.persistence.ManyToOne +import jakarta.persistence.Table +import nexters.weski.common.BaseEntity + +@Entity +@Table(name = "oauth_accounts") +data class OAuthAccount( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false, foreignKey = ForeignKey(name = "fk_oauth_user")) + val user: User, + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + val provider: OAuthProvider, + + @Column(nullable = false, length = 100, name = "provider_id") + val providerId: String, + + @Column(nullable = true, length = 100) + var email: String? = null, + + @Column(nullable = true, length = 500, name = "apple_auth_code") + var appleAuthCode: String? = null + +) : BaseEntity() + +enum class OAuthProvider { + KAKAO, + APPLE +} diff --git a/src/main/kotlin/nexters/weski/user/OAuthAccountRepository.kt b/src/main/kotlin/nexters/weski/user/OAuthAccountRepository.kt new file mode 100644 index 0000000..05c1224 --- /dev/null +++ b/src/main/kotlin/nexters/weski/user/OAuthAccountRepository.kt @@ -0,0 +1,56 @@ +package nexters.weski.user + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query +import org.springframework.data.repository.query.Param + +interface OAuthAccountRepository : JpaRepository { + + // 소셜 로그인 시 기존 계정 찾기 + fun findByProviderAndProviderId( + provider: OAuthProvider, + providerId: String + ): OAuthAccount? + + // 사용자의 모든 소셜 계정 조회 + fun findByUserId(userId: Long): List + + // 사용자의 특정 플랫폼 계정 조회 + fun findByUserIdAndProvider( + userId: Long, + provider: OAuthProvider + ): OAuthAccount? + + // 사용자가 특정 플랫폼에 연결되어 있는지 확인 + fun existsByUserIdAndProvider( + userId: Long, + provider: OAuthProvider + ): Boolean + + // 이메일로 계정 찾기 (계정 통합 시 사용) + fun findByEmail(email: String): List + + // 사용자의 연결된 소셜 계정 수 조회 + fun countByUserId(userId: Long): Long + + // Apple 계정 탈퇴를 위한 AuthCode가 있는 계정 조회 + @Query( + """ + SELECT o + FROM OAuthAccount o + WHERE o.user.id = :userId + AND o.provider = 'APPLE' + AND o.appleAuthCode IS NOT NULL + """ + ) + fun findAppleAccountWithAuthCode(@Param("userId") userId: Long): OAuthAccount? + + // 제공자별 계정 수 통계 + fun countByProvider(provider: OAuthProvider): Long + + // 특정 사용자의 모든 OAuth 계정 삭제 + fun deleteByUserId(userId: Long) + + // 특정 제공자의 모든 계정 조회 (관리자용) + fun findByProvider(provider: OAuthProvider): List +} diff --git a/src/main/kotlin/nexters/weski/user/RefreshToken.kt b/src/main/kotlin/nexters/weski/user/RefreshToken.kt new file mode 100644 index 0000000..fc5d276 --- /dev/null +++ b/src/main/kotlin/nexters/weski/user/RefreshToken.kt @@ -0,0 +1,28 @@ +package nexters.weski.user + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.Table +import nexters.weski.common.BaseEntity +import java.time.LocalDateTime + +@Entity +@Table(name = "refresh_tokens") +data class RefreshToken( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + @Column(nullable = false) + val userId: Long, + + @Column(nullable = false, length = 500) + val token: String, + + @Column(nullable = false) + val expiresAt: LocalDateTime + +) : BaseEntity() diff --git a/src/main/kotlin/nexters/weski/user/RefreshTokenRepository.kt b/src/main/kotlin/nexters/weski/user/RefreshTokenRepository.kt new file mode 100644 index 0000000..baecf8d --- /dev/null +++ b/src/main/kotlin/nexters/weski/user/RefreshTokenRepository.kt @@ -0,0 +1,13 @@ +package nexters.weski.user + +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository +import java.time.LocalDateTime + +@Repository +interface RefreshTokenRepository : JpaRepository { + fun findByToken(token: String): RefreshToken? + fun findByUserId(userId: Long): RefreshToken? + fun deleteByUserId(userId: Long) + fun deleteByExpiresAtBefore(dateTime: LocalDateTime) +} diff --git a/src/main/kotlin/nexters/weski/user/User.kt b/src/main/kotlin/nexters/weski/user/User.kt new file mode 100644 index 0000000..cab75e1 --- /dev/null +++ b/src/main/kotlin/nexters/weski/user/User.kt @@ -0,0 +1,44 @@ +package nexters.weski.user + +import jakarta.persistence.CascadeType +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.GeneratedValue +import jakarta.persistence.GenerationType +import jakarta.persistence.Id +import jakarta.persistence.OneToMany +import jakarta.persistence.Table +import nexters.weski.common.BaseEntity + +@Entity +@Table(name = "users") +data class User( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + val id: Long? = null, + + @Column(nullable = true, length = 50) + var nickname: String, + + @Column(nullable = true, length = 500) + var profileImageUrl: String? = null, + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + var status: AccountStatus = AccountStatus.ACTIVE, + + @OneToMany(mappedBy = "user", cascade = [CascadeType.ALL], orphanRemoval = true) + val oauthAccounts: MutableList = mutableListOf(), + + @OneToMany(mappedBy = "user", cascade = [CascadeType.ALL], orphanRemoval = true) + val deviceAccounts: MutableList = mutableListOf() + +) : BaseEntity() + +enum class AccountStatus { + ACTIVE, + INACTIVE, + DELETED +} diff --git a/src/main/kotlin/nexters/weski/user/UserController.kt b/src/main/kotlin/nexters/weski/user/UserController.kt new file mode 100644 index 0000000..100ebc0 --- /dev/null +++ b/src/main/kotlin/nexters/weski/user/UserController.kt @@ -0,0 +1,132 @@ +package nexters.weski.user + +import io.swagger.v3.oas.annotations.Operation +import io.swagger.v3.oas.annotations.Parameter +import io.swagger.v3.oas.annotations.tags.Tag +import jakarta.validation.Valid +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.DeleteMapping +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@Tag(name = "사용자 API", description = "사용자 인증, 프로필 관리 및 계정 연동 API") +@RestController +@RequestMapping("/api/users") +class UserController( + private val userService: UserService, +) { + @Operation(summary = "소셜 로그인/회원가입", description = "OAuth 제공자를 통한 로그인 또는 회원가입을 수행합니다") + @PostMapping("/auth/social") + fun socialLogin( + @Valid @RequestBody request: SocialLoginRequest, + ): ResponseEntity> { + val response = userService.loginOrSignup( + provider = request.provider, + providerId = request.providerId, + email = request.email, + nickname = request.nickname, + profileImageUrl = request.profileImageUrl, + deviceId = request.deviceId, + deviceType = request.deviceType, + appleAuthCode = request.appleAuthCode + ) + return ResponseEntity.ok( + ApiResponse.success("로그인이 성공적으로 처리되었습니다", response) + ) + } + + @Operation(summary = "디바이스 로그인", description = "디바이스 기반 로그인 또는 회원가입을 수행합니다") + @PostMapping("/auth/device") + fun deviceLogin( + @Valid @RequestBody request: DeviceLoginRequest, + ): ResponseEntity> { + val response = userService.deviceLogin( + deviceId = request.deviceId, + deviceType = request.deviceType, + nickname = request.nickname + ) + return ResponseEntity.ok( + ApiResponse.success("디바이스 로그인이 성공적으로 처리되었습니다", response) + ) + } + + @Operation(summary = "사용자 프로필 조회", description = "현재 사용자의 프로필 정보를 조회합니다") + @GetMapping("/{userId}/profile") + fun getUserProfile( + @Parameter(description = "사용자 ID", example = "1") + @PathVariable userId: Long, + ): ResponseEntity> { + val profile = userService.getUserProfile(userId) + return ResponseEntity.ok( + ApiResponse.success("프로필 정보를 성공적으로 조회했습니다", profile) + ) + } + + @Operation(summary = "사용자 프로필 수정", description = "사용자의 프로필 정보를 수정합니다") + @PutMapping("/{userId}/profile") + fun updateUserProfile( + @Parameter(description = "사용자 ID", example = "1") + @PathVariable userId: Long, + @Valid @RequestBody request: UpdateProfileRequest, + ): ResponseEntity> { + val updatedProfile = userService.updateUserProfile( + userId = userId, + nickname = request.nickname, + profileImageUrl = request.profileImageUrl + ) + return ResponseEntity.ok( + ApiResponse.success("프로필이 성공적으로 수정되었습니다", updatedProfile) + ) + } + + @Operation(summary = "소셜 계정 연동", description = "기존 계정에 새로운 소셜 계정을 연동합니다") + @PostMapping("/{userId}/oauth/link") + fun linkOAuthAccount( + @Parameter(description = "사용자 ID", example = "1") + @PathVariable userId: Long, + @Valid @RequestBody request: LinkOAuthRequest, + ): ResponseEntity> { + userService.linkOAuthAccount( + userId = userId, + provider = request.provider, + providerId = request.providerId, + email = request.email, + appleAuthCode = request.appleAuthCode + ) + return ResponseEntity.ok( + ApiResponse.success("${request.provider.name} 계정이 성공적으로 연동되었습니다") + ) + } + + @Operation(summary = "소셜 계정 연동 해제", description = "연동된 소셜 계정을 해제합니다") + @DeleteMapping("/{userId}/oauth/{provider}") + fun unlinkOAuthAccount( + @Parameter(description = "사용자 ID", example = "1") + @PathVariable userId: Long, + @Parameter(description = "OAuth 제공자", example = "KAKAO") + @PathVariable provider: OAuthProvider, + ): ResponseEntity> { + userService.unlinkOAuthAccount(userId, provider) + return ResponseEntity.ok( + ApiResponse.success("${provider.name} 계정 연동이 성공적으로 해제되었습니다") + ) + } + + @Operation(summary = "회원 탈퇴", description = "사용자 계정을 탈퇴 처리합니다") + @DeleteMapping("/{userId}") + fun withdrawUser( + @Parameter(description = "사용자 ID", example = "1") + @PathVariable userId: Long, + ): ResponseEntity> { + userService.withdrawUser(userId) + return ResponseEntity.ok( + ApiResponse.success("회원 탈퇴가 성공적으로 처리되었습니다") + ) + } +} \ No newline at end of file diff --git a/src/main/kotlin/nexters/weski/user/UserDto.kt b/src/main/kotlin/nexters/weski/user/UserDto.kt new file mode 100644 index 0000000..a1c6026 --- /dev/null +++ b/src/main/kotlin/nexters/weski/user/UserDto.kt @@ -0,0 +1,257 @@ +package nexters.weski.user + +import io.swagger.v3.oas.annotations.media.Schema +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.NotNull +import java.time.LocalDateTime + +/** + * 소셜 로그인/회원가입 요청 DTO + */ +@Schema(description = "소셜 로그인/회원가입 요청 DTO") +data class SocialLoginRequest( + @field:NotNull(message = "OAuth 제공자는 필수입니다") + @Schema(description = "OAuth 제공자", example = "KAKAO") + val provider: OAuthProvider, + + @field:NotBlank(message = "제공자 ID는 필수입니다") + @Schema(description = "OAuth 제공자의 사용자 ID", example = "kakao_123456789") + val providerId: String, + + @Schema(description = "이메일", example = "user@example.com") + val email: String? = null, + + @field:NotBlank(message = "닉네임은 필수입니다") + @Schema(description = "사용자 닉네임", example = "스키러버") + val nickname: String, + + @Schema(description = "프로필 이미지 URL", example = "https://example.com/profile.jpg") + val profileImageUrl: String? = null, + + @field:NotBlank(message = "디바이스 ID는 필수입니다") + @Schema(description = "디바이스 ID", example = "device_12345") + val deviceId: String, + + @field:NotNull(message = "디바이스 타입은 필수입니다") + @Schema(description = "디바이스 타입", example = "IOS") + val deviceType: DeviceType, + + @Schema(description = "Apple AuthCode (Apple 로그인 시만)", example = "apple_auth_code_12345") + val appleAuthCode: String? = null, +) + +/** + * 디바이스 로그인 요청 DTO + */ +@Schema(description = "디바이스 로그인 요청 DTO") +data class DeviceLoginRequest( + @field:NotBlank(message = "디바이스 ID는 필수입니다") + @Schema(description = "디바이스 ID", example = "device_12345") + val deviceId: String, + + @field:NotNull(message = "디바이스 타입은 필수입니다") + @Schema(description = "디바이스 타입", example = "ANDROID") + val deviceType: DeviceType, + + @Schema(description = "사용자 닉네임", example = "익명사용자") + val nickname: String? = null, +) + +/** + * 프로필 수정 요청 DTO + */ +@Schema(description = "프로필 수정 요청 DTO") +data class UpdateProfileRequest( + @Schema(description = "닉네임", example = "새로운닉네임") + val nickname: String? = null, + + @Schema(description = "프로필 이미지 URL", example = "https://example.com/new_profile.jpg") + val profileImageUrl: String? = null, +) + +/** + * 소셜 계정 연동 요청 DTO + */ +@Schema(description = "소셜 계정 연동 요청 DTO") +data class LinkOAuthRequest( + @field:NotNull(message = "OAuth 제공자는 필수입니다") + @Schema(description = "OAuth 제공자", example = "APPLE") + val provider: OAuthProvider, + + @field:NotBlank(message = "제공자 ID는 필수입니다") + @Schema(description = "OAuth 제공자의 사용자 ID", example = "apple_123456789") + val providerId: String, + + @Schema(description = "이메일", example = "user@apple.com") + val email: String? = null, + + @Schema(description = "Apple AuthCode (Apple 로그인 시만)", example = "apple_auth_code_12345") + val appleAuthCode: String? = null, +) + +/** + * 로그인/회원가입 응답 DTO + */ +@Schema(description = "로그인/회원가입 응답 DTO") +data class UserLoginResponse( + @Schema(description = "사용자 ID", example = "1") + val userId: Long, + + @Schema(description = "닉네임", example = "스키러버") + val nickname: String, + + @Schema(description = "프로필 이미지 URL", example = "https://example.com/profile.jpg") + val profileImageUrl: String?, + + @Schema(description = "계정 상태", example = "ACTIVE") + val status: String, + + @Schema(description = "신규 가입 여부", example = "false") + val isNewUser: Boolean, + + @Schema(description = "생성일시", example = "2024-01-01T10:00:00") + val createdAt: String, + + @Schema(description = "액세스 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + val accessToken: String, + + @Schema(description = "리프레시 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + val refreshToken: String +) { + companion object { + fun fromExistingUser( + user: User, + isNewUser: Boolean, + accessToken: String, + refreshToken: String + ): UserLoginResponse = + UserLoginResponse( + userId = user.id!!, + nickname = user.nickname, + profileImageUrl = user.profileImageUrl, + status = user.status.name, + isNewUser = isNewUser, + createdAt = user.createdAt.toString(), + accessToken = accessToken, + refreshToken = refreshToken + ) + } +} + +/** + * 사용자 프로필 응답 DTO + */ +@Schema(description = "사용자 프로필 응답 DTO") +data class UserProfileResponse( + @Schema(description = "사용자 ID", example = "1") + val userId: Long, + + @Schema(description = "닉네임", example = "스키러버") + val nickname: String, + + @Schema(description = "프로필 이미지 URL", example = "https://example.com/profile.jpg") + val profileImageUrl: String?, + + @Schema(description = "계정 상태", example = "ACTIVE") + val status: String, + + @Schema(description = "연동된 OAuth 계정 목록") + val oauthAccounts: List, + + @Schema(description = "등록된 디바이스 목록") + val deviceAccounts: List, + + @Schema(description = "생성일시", example = "2024-01-01T10:00:00") + val createdAt: String, + + @Schema(description = "수정일시", example = "2024-01-01T10:00:00") + val updatedAt: String, +) { + companion object { + fun fromEntity( + user: User, + oauthAccounts: List, + deviceAccounts: List, + ): UserProfileResponse = + UserProfileResponse( + userId = user.id!!, + nickname = user.nickname, + profileImageUrl = user.profileImageUrl, + status = user.status.name, + oauthAccounts = oauthAccounts.map { OAuthAccountDto.fromEntity(it) }, + deviceAccounts = deviceAccounts.map { DeviceAccountDto.fromEntity(it) }, + createdAt = user.createdAt.toString(), + updatedAt = user.updatedAt.toString() + ) + } +} + +/** + * OAuth 계정 정보 DTO + */ +@Schema(description = "OAuth 계정 정보 DTO") +data class OAuthAccountDto( + @Schema(description = "OAuth 제공자", example = "KAKAO") + val provider: String, + + @Schema(description = "이메일", example = "user@example.com") + val email: String?, + + @Schema(description = "연동일시", example = "2024-01-01T10:00:00") + val createdAt: String, +) { + companion object { + fun fromEntity(oauthAccount: OAuthAccount): OAuthAccountDto = + OAuthAccountDto( + provider = oauthAccount.provider.name, + email = oauthAccount.email, + createdAt = oauthAccount.createdAt.toString() + ) + } +} + +/** + * 디바이스 계정 정보 DTO + */ +@Schema(description = "디바이스 계정 정보 DTO") +data class DeviceAccountDto( + @Schema(description = "디바이스 ID", example = "device_12345") + val deviceId: String, + + @Schema(description = "디바이스 타입", example = "IOS") + val deviceType: String, + + @Schema(description = "등록일시", example = "2024-01-01T10:00:00") + val createdAt: String, +) { + companion object { + fun fromEntity(deviceAccount: DeviceAccount): DeviceAccountDto = + DeviceAccountDto( + deviceId = deviceAccount.deviceId, + deviceType = deviceAccount.deviceType.name, + createdAt = deviceAccount.createdAt.toString() + ) + } +} + +/** + * 공통 API 응답 DTO (재사용) + */ +@Schema(description = "API 응답 DTO") +data class ApiResponse( + @Schema(description = "성공 여부", example = "true") + val success: Boolean, + @Schema(description = "응답 메시지", example = "성공적으로 처리되었습니다") + val message: String, + @Schema(description = "응답 데이터") + val data: T? = null, +) { + companion object { + fun success( + message: String, + data: T? = null, + ): ApiResponse = ApiResponse(true, message, data) + + fun error(message: String): ApiResponse = ApiResponse(false, message) + } +} \ No newline at end of file diff --git a/src/main/kotlin/nexters/weski/user/UserRepository.kt b/src/main/kotlin/nexters/weski/user/UserRepository.kt new file mode 100644 index 0000000..112202a --- /dev/null +++ b/src/main/kotlin/nexters/weski/user/UserRepository.kt @@ -0,0 +1,21 @@ +package nexters.weski.user + +import org.springframework.data.jpa.repository.JpaRepository + +interface UserRepository : JpaRepository { + + // 특정 상태의 사용자들 조회 (관리자 기능) + fun findByStatus(status: AccountStatus): List + + // 사용자 ID와 상태로 존재 여부 확인 (활성 사용자 검증) + fun existsByIdAndStatus(id: Long, status: AccountStatus): Boolean + + // 닉네임으로 사용자 검색 (부분 일치, 대소문자 무시) + fun findByNicknameContainingIgnoreCase(nickname: String): List + + // 특정 기간 내 생성된 사용자 조회 + fun findByCreatedAtBetween(startDate: java.time.LocalDateTime, endDate: java.time.LocalDateTime): List + + // 사용자 수 통계 (상태별) + fun countByStatus(status: AccountStatus): Long +} diff --git a/src/main/kotlin/nexters/weski/user/UserService.kt b/src/main/kotlin/nexters/weski/user/UserService.kt new file mode 100644 index 0000000..94ef86c --- /dev/null +++ b/src/main/kotlin/nexters/weski/user/UserService.kt @@ -0,0 +1,261 @@ +package nexters.weski.user + +import nexters.weski.auth.JwtTokenProvider +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.time.LocalDateTime + +@Service +@Transactional +class UserService( + private val userRepository: UserRepository, + private val oauthAccountRepository: OAuthAccountRepository, + private val deviceAccountRepository: DeviceAccountRepository, + private val jwtTokenProvider: JwtTokenProvider, + private val refreshTokenRepository: RefreshTokenRepository, +) { + /** + * 소셜 로그인 또는 회원가입 + * 기존 계정이 있으면 로그인, 없으면 회원가입 + */ + fun loginOrSignup( + provider: OAuthProvider, + providerId: String, + email: String? = null, + nickname: String, + profileImageUrl: String? = null, + deviceId: String, + deviceType: DeviceType, + appleAuthCode: String? = null, + ): UserLoginResponse { + // 1. 기존 OAuth 계정 확인 + val existingOAuthAccount = oauthAccountRepository.findByProviderAndProviderId(provider, providerId) + + val (user, isNewUser) = if (existingOAuthAccount != null) { + // 기존 사용자 로그인 + val user = existingOAuthAccount.user + + // 디바이스 등록 (중복이면 무시) + registerDeviceIfNotExists(user, deviceId, deviceType) + + Pair(user, false) + } else { + // 새 사용자 회원가입 + val newUser = createNewUser(nickname, profileImageUrl) + + // OAuth 계정 생성 + createOAuthAccount(newUser, provider, providerId, email, appleAuthCode) + + // 디바이스 등록 + registerDeviceIfNotExists(newUser, deviceId, deviceType) + + Pair(newUser, true) + } + + // JWT 토큰 생성 + val tokens = generateTokens(user.id!!) + + return UserLoginResponse.fromExistingUser(user, isNewUser, tokens.first, tokens.second) + } + + /** + * 디바이스 로그인 (OAuth 없이) + * 기존 디바이스면 해당 사용자 반환, 새 디바이스면 새 사용자 생성 + */ + fun deviceLogin( + deviceId: String, + deviceType: DeviceType, + nickname: String? = null, + ): UserLoginResponse { + // 기존 디바이스 계정 확인 + val existingDeviceAccount = deviceAccountRepository.findByDeviceId(deviceId) + + val (user, isNewUser) = if (existingDeviceAccount != null) { + // 기존 사용자 로그인 + Pair(existingDeviceAccount.user, false) + } else { + // 새 사용자 생성 + val newUser = createNewUser(nickname ?: "사용자", null) + + // 디바이스 계정 생성 + val deviceAccount = DeviceAccount( + user = newUser, + deviceId = deviceId, + deviceType = deviceType + ) + deviceAccountRepository.save(deviceAccount) + + Pair(newUser, true) + } + + // JWT 토큰 생성 + val tokens = generateTokens(user.id!!) + + return UserLoginResponse.fromExistingUser(user, isNewUser, tokens.first, tokens.second) + } + + /** + * 사용자 프로필 조회 + */ + @Transactional(readOnly = true) + fun getUserProfile(userId: Long): UserProfileResponse { + val user = userRepository.findById(userId) + .orElseThrow { IllegalArgumentException("사용자를 찾을 수 없습니다: $userId") } + + val oauthAccounts = oauthAccountRepository.findByUserId(userId) + val deviceAccounts = deviceAccountRepository.findByUserId(userId) + + return UserProfileResponse.fromEntity(user, oauthAccounts, deviceAccounts) + } + + /** + * 사용자 프로필 수정 + */ + fun updateUserProfile( + userId: Long, + nickname: String?, + profileImageUrl: String?, + ): UserProfileResponse { + val user = userRepository.findById(userId) + .orElseThrow { IllegalArgumentException("사용자를 찾을 수 없습니다: $userId") } + + nickname?.let { user.nickname = it } + profileImageUrl?.let { user.profileImageUrl = it } + + val updatedUser = userRepository.save(user) + + val oauthAccounts = oauthAccountRepository.findByUserId(userId) + val deviceAccounts = deviceAccountRepository.findByUserId(userId) + + return UserProfileResponse.fromEntity(updatedUser, oauthAccounts, deviceAccounts) + } + + /** + * 소셜 계정 연동 + */ + fun linkOAuthAccount( + userId: Long, + provider: OAuthProvider, + providerId: String, + email: String? = null, + appleAuthCode: String? = null, + ) { + val user = userRepository.findById(userId) + .orElseThrow { IllegalArgumentException("사용자를 찾을 수 없습니다: $userId") } + + // 이미 연동된 플랫폼인지 확인 + if (oauthAccountRepository.existsByUserIdAndProvider(userId, provider)) { + throw IllegalStateException("이미 ${provider.name} 계정이 연동되어 있습니다") + } + + // 다른 사용자가 이미 사용 중인 계정인지 확인 + val existingAccount = oauthAccountRepository.findByProviderAndProviderId(provider, providerId) + if (existingAccount != null) { + throw IllegalStateException("이미 다른 계정에 연동된 ${provider.name} 계정입니다") + } + + createOAuthAccount(user, provider, providerId, email, appleAuthCode) + } + + /** + * 소셜 계정 연동 해제 + */ + fun unlinkOAuthAccount(userId: Long, provider: OAuthProvider) { + val user = userRepository.findById(userId) + .orElseThrow { IllegalArgumentException("사용자를 찾을 수 없습니다: $userId") } + + val oauthAccount = oauthAccountRepository.findByUserIdAndProvider(userId, provider) + ?: throw IllegalArgumentException("연동된 ${provider.name} 계정을 찾을 수 없습니다") + + // 마지막 계정인지 확인 (디바이스 계정도 없으면 안됨) + val oauthCount = oauthAccountRepository.countByUserId(userId) + val deviceCount = deviceAccountRepository.findByUserId(userId).size + + if (oauthCount <= 1 && deviceCount == 0) { + throw IllegalStateException("마지막 연동 계정은 해제할 수 없습니다") + } + + oauthAccountRepository.delete(oauthAccount) + } + + /** + * 회원 탈퇴 + */ + fun withdrawUser(userId: Long) { + val user = userRepository.findById(userId) + .orElseThrow { IllegalArgumentException("사용자를 찾을 수 없습니다: $userId") } + + // Apple 계정이 있으면 AuthCode 확인 + val appleAccount = oauthAccountRepository.findAppleAccountWithAuthCode(userId) + if (appleAccount == null) { + val hasApple = oauthAccountRepository.existsByUserIdAndProvider(userId, OAuthProvider.APPLE) + if (hasApple) { + throw IllegalStateException("Apple 계정 탈퇴를 위해서는 AuthCode가 필요합니다") + } + } + + // 사용자 상태를 DELETED로 변경 (실제 삭제 대신) + user.status = AccountStatus.DELETED + userRepository.save(user) + } + + // === Private Helper Methods === + + private fun createNewUser(nickname: String, profileImageUrl: String?): User { + val user = User( + nickname = nickname, + profileImageUrl = profileImageUrl, + status = AccountStatus.ACTIVE + ) + return userRepository.save(user) + } + + private fun createOAuthAccount( + user: User, + provider: OAuthProvider, + providerId: String, + email: String?, + appleAuthCode: String?, + ) { + val oauthAccount = OAuthAccount( + user = user, + provider = provider, + providerId = providerId, + email = email, + appleAuthCode = appleAuthCode + ) + oauthAccountRepository.save(oauthAccount) + } + + private fun registerDeviceIfNotExists(user: User, deviceId: String, deviceType: DeviceType) { + if (!deviceAccountRepository.existsByUserIdAndDeviceId(user.id!!, deviceId)) { + val deviceAccount = DeviceAccount( + user = user, + deviceId = deviceId, + deviceType = deviceType + ) + deviceAccountRepository.save(deviceAccount) + } + } + + /** + * JWT 토큰 생성 및 RefreshToken 저장 + * @return Pair(accessToken, refreshToken) + */ + private fun generateTokens(userId: Long): Pair { + val accessToken = jwtTokenProvider.generateAccessToken(userId) + val refreshToken = jwtTokenProvider.generateRefreshToken(userId) + + // 기존 RefreshToken 삭제 후 새로 저장 + refreshTokenRepository.deleteByUserId(userId) + + val refreshTokenEntity = RefreshToken( + userId = userId, + token = refreshToken, + expiresAt = LocalDateTime.now().plusDays(7) + ) + refreshTokenRepository.save(refreshTokenEntity) + + return Pair(accessToken, refreshToken) + } +} \ No newline at end of file diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index ae419eb..ccff8b8 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -38,5 +38,10 @@ tmap: api: key: ${TMAP_API_KEY} +jwt: + secret: ${JWT_SECRET:your-256-bit-secret-key-for-development-only-please-change-in-production} + access-token-expiration: 3600000 # 1시간 (밀리초) + refresh-token-expiration: 604800000 # 7일 (밀리초) + server: port: ${PORT:8080} \ No newline at end of file