From 8470b6de898d091a31f7a41de07d4c65818275d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=EC=A7=80=EC=88=98?= Date: Thu, 5 Mar 2026 15:37:50 +0900 Subject: [PATCH 01/17] =?UTF-8?q?:sparkle:=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=ED=83=88=ED=87=B4=20api=20=EB=B0=8F=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/user/controller/UserController.kt | 46 ++--------- .../domain/user/exception/UserErrorCode.kt | 5 ++ .../domain/user/service/UserService.kt | 80 +++---------------- 3 files changed, 25 insertions(+), 106 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt index 85c924bd..a069d126 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt @@ -13,6 +13,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse import io.swagger.v3.oas.annotations.responses.ApiResponses import io.swagger.v3.oas.annotations.tags.Tag 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.PatchMapping import org.springframework.web.bind.annotation.RequestBody @@ -33,13 +34,11 @@ class UserController( ApiResponse(responseCode = "401", description = "인증 실패 (유효하지 않은 토큰)"), ], ) - @AuthRequired @GetMapping("/me") fun me( @Parameter(hidden = true) @LoggedInUser user: User, ): ResponseEntity = ResponseEntity.ok(userService.me(user)) - @AuthRequired @PatchMapping("/me") fun patchMe( @Parameter(hidden = true) @LoggedInUser user: User, @@ -55,40 +54,11 @@ class UserController( return ResponseEntity.ok(userService.me(user)) } -// @Operation(summary = "프로필 이미지 업로드", description = "로그인한 사용자의 프로필 이미지를 업로드(교체)합니다") -// @ApiResponses( -// value = [ -// ApiResponse(responseCode = "204", description = "프로필 이미지 업로드 성공"), -// ApiResponse(responseCode = "400", description = "잘못된 요청 (파일 누락/형식 오류 등)"), -// ApiResponse(responseCode = "401", description = "인증 실패 (유효하지 않은 토큰)"), -// ], -// ) -// @AuthRequired -// @PutMapping( -// "/me/profile-image", -// consumes = [MediaType.MULTIPART_FORM_DATA_VALUE], -// ) -// fun uploadProfileImage( -// @Parameter(hidden = true) @LoggedInUser user: User?, -// @RequestPart("image") image: MultipartFile, -// ): ResponseEntity { -// userService.updateProfileImage(user, image) -// return ResponseEntity.noContent().build() -// } -// -// @Operation(summary = "프로필 이미지 삭제", description = "로그인한 사용자의 프로필 이미지를 삭제(기본 이미지로 복귀)합니다") -// @ApiResponses( -// value = [ -// ApiResponse(responseCode = "204", description = "프로필 이미지 삭제 성공"), -// ApiResponse(responseCode = "401", description = "인증 실패 (유효하지 않은 토큰)"), -// ], -// ) -// @AuthRequired -// @DeleteMapping("/me/profile-image") -// fun deleteProfileImage( -// @Parameter(hidden = true) @LoggedInUser user: User?, -// ): ResponseEntity { -// userService.deleteProfileImage(user) -// return ResponseEntity.noContent().build() -// } + @DeleteMapping("/me") + fun deleteMe( + @Parameter(hidden = true) @LoggedInUser user: User, + ): ResponseEntity { + userService.deleteMe(user = user) + return ResponseEntity.ok().build() + } } diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/exception/UserErrorCode.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/exception/UserErrorCode.kt index f0c68bfe..348a5835 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/exception/UserErrorCode.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/exception/UserErrorCode.kt @@ -18,4 +18,9 @@ enum class UserErrorCode( title = "이메일은 변경할 수 없습니다.", message = "이메일 주소는 변경이 불가능합니다.", ), + NO_SUCH_USER( + httpStatusCode = HttpStatus.NOT_FOUND, + title = "존재하지 않는 회원입니다.", + message = "이미 탈퇴 처리가 되었거나\n존재하지 않는 회원입니다." + ) } diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/service/UserService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/service/UserService.kt index 321f1e6e..cf27940e 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/service/UserService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/service/UserService.kt @@ -8,6 +8,7 @@ import com.wafflestudio.spring2025.domain.auth.exception.AuthenticationRequiredE import com.wafflestudio.spring2025.domain.user.dto.core.UserDto import com.wafflestudio.spring2025.domain.user.exception.EmailChangeForbiddenException import com.wafflestudio.spring2025.domain.user.exception.UserErrorCode +import com.wafflestudio.spring2025.domain.user.exception.UserException import com.wafflestudio.spring2025.domain.user.exception.UserValidationException import com.wafflestudio.spring2025.domain.user.model.User import com.wafflestudio.spring2025.domain.user.repository.UserRepository @@ -58,6 +59,17 @@ class UserService( userRepository.save(user) } + fun deleteMe( + user: User, + ) { + try { + userRepository.delete(user) + } catch (e: Exception) { + throw UserException(UserErrorCode.NO_SUCH_USER) + } + + } + private fun validateName(name: String) { if (name.isBlank()) { throw AuthValidationException(AuthErrorCode.BAD_NAME) @@ -89,72 +101,4 @@ class UserService( throw UserValidationException(UserErrorCode.PROFILE_IMAGE_NOT_FOUND) } } - -// -// fun updateProfileImage( -// user: User?, -// image: MultipartFile, -// ) { -// if (user == null) throw AuthenticationRequiredException() -// validateProfileImage(image) -// -// val ext = extractExtension(image.originalFilename) -// val key = "profile-images/${user.id}/${UUID.randomUUID()}$ext" -// -// val putReq = -// PutObjectRequest -// .builder() -// .bucket(s3Props.bucket) -// .key(key) -// .contentType(image.contentType ?: "application/octet-stream") -// .build() -// -// image.inputStream.use { input -> -// s3Client.putObject( -// putReq, -// RequestBody.fromInputStream(input, image.size), -// ) -// } -// -// // DB에는 URL이 아니라 key 저장 -// user.profileImage = key -// userRepository.save(user) -// } -// -// fun deleteProfileImage(user: User?) { -// if (user == null) throw AuthenticationRequiredException() -// -// // (선택) S3에서도 삭제 -// user.profileImage?.let { key -> -// val delReq = -// DeleteObjectRequest -// .builder() -// .bucket(s3Props.bucket) -// .key(key) -// .build() -// runCatching { s3Client.deleteObject(delReq) } -// } -// -// user.profileImage = null -// userRepository.save(user) -// } -// - -// -// private fun validateProfileImage(image: MultipartFile) { -// if (image.isEmpty) throw UserValidationException(UserErrorCode.PROFILE_IMAGE_EMPTY) -// val contentType = image.contentType ?: "" -// if (!contentType.startsWith("image/")) throw UserValidationException(UserErrorCode.PROFILE_IMAGE_FORMAT_INVALID) -// -// val maxSizeBytes = 5L * 1024L * 1024L -// if (image.size > maxSizeBytes) throw UserValidationException(UserErrorCode.PROFILE_IMAGE_TOO_LARGE) -// } -// -// private fun extractExtension(originalFilename: String?): String { -// if (originalFilename.isNullOrBlank()) return ".jpg" -// val idx = originalFilename.lastIndexOf('.') -// if (idx < 0) return ".jpg" -// val ext = originalFilename.substring(idx).lowercase() -// return if (ext in setOf(".jpg", ".jpeg", ".png", ".webp")) ext else ".jpg" -// } } From f66eec2dc611cec4934d5f351ef852a24b4b090e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=EC=A7=80=EC=88=98?= Date: Thu, 5 Mar 2026 15:39:46 +0900 Subject: [PATCH 02/17] =?UTF-8?q?:sparkle:=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring2025/domain/user/controller/UserController.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt index a069d126..7991d864 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt @@ -59,6 +59,6 @@ class UserController( @Parameter(hidden = true) @LoggedInUser user: User, ): ResponseEntity { userService.deleteMe(user = user) - return ResponseEntity.ok().build() + return ResponseEntity.noContent().build() } } From 4dfbca8b42b001a4d86c441d746d3de3bb6e549e Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Thu, 5 Mar 2026 19:45:33 +0900 Subject: [PATCH 03/17] :memo: User Controller swagger documentation correction --- .../domain/user/controller/UserController.kt | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt index 7991d864..23936d9e 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt @@ -31,7 +31,7 @@ class UserController( @ApiResponses( value = [ ApiResponse(responseCode = "200", description = "사용자 정보 조회 성공"), - ApiResponse(responseCode = "401", description = "인증 실패 (유효하지 않은 토큰)"), + ApiResponse(responseCode = "401", description = "AUTHENTICATION_REQUIRED - 로그인이 필요합니다"), ], ) @GetMapping("/me") @@ -39,6 +39,15 @@ class UserController( @Parameter(hidden = true) @LoggedInUser user: User, ): ResponseEntity = ResponseEntity.ok(userService.me(user)) + @Operation(summary = "본인 정보 수정", description = "로그인한 사용자의 정보를 수정합니다") + @ApiResponses( + value = [ + ApiResponse(responseCode = "200", description = "사용자 정보 수정 성공"), + ApiResponse(responseCode = "400", description = "BAD_NAME - 이름 형식 오류 | BAD_PASSWORD - 비밀번호 형식 오류 | PROFILE_IMAGE_NOT_FOUND - 존재하지 않는 이미지"), + ApiResponse(responseCode = "401", description = "AUTHENTICATION_REQUIRED - 로그인이 필요합니다"), + ApiResponse(responseCode = "403", description = "EMAIL_CHANGE_FORBIDDEN - 이메일은 변경할 수 없습니다"), + ], + ) @PatchMapping("/me") fun patchMe( @Parameter(hidden = true) @LoggedInUser user: User, @@ -54,6 +63,14 @@ class UserController( return ResponseEntity.ok(userService.me(user)) } + @Operation(summary = "회원 탈퇴", description = "로그인한 사용자의 계정을 삭제합니다") + @ApiResponses( + value = [ + ApiResponse(responseCode = "204", description = "회원 탈퇴 성공"), + ApiResponse(responseCode = "401", description = "AUTHENTICATION_REQUIRED - 로그인이 필요합니다"), + ApiResponse(responseCode = "404", description = "NO_SUCH_USER - 존재하지 않는 회원입니다"), + ], + ) @DeleteMapping("/me") fun deleteMe( @Parameter(hidden = true) @LoggedInUser user: User, From 9f7e9d143a4449e0a638f63816d600437ef6bab0 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Thu, 5 Mar 2026 19:46:15 +0900 Subject: [PATCH 04/17] :art: ktlint --- .../spring2025/domain/user/controller/UserController.kt | 5 ++++- .../spring2025/domain/user/exception/UserErrorCode.kt | 4 ++-- .../spring2025/domain/user/service/UserService.kt | 5 +---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt index 23936d9e..61a12238 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt @@ -43,7 +43,10 @@ class UserController( @ApiResponses( value = [ ApiResponse(responseCode = "200", description = "사용자 정보 수정 성공"), - ApiResponse(responseCode = "400", description = "BAD_NAME - 이름 형식 오류 | BAD_PASSWORD - 비밀번호 형식 오류 | PROFILE_IMAGE_NOT_FOUND - 존재하지 않는 이미지"), + ApiResponse( + responseCode = "400", + description = "BAD_NAME - 이름 형식 오류 | BAD_PASSWORD - 비밀번호 형식 오류 | PROFILE_IMAGE_NOT_FOUND - 존재하지 않는 이미지", + ), ApiResponse(responseCode = "401", description = "AUTHENTICATION_REQUIRED - 로그인이 필요합니다"), ApiResponse(responseCode = "403", description = "EMAIL_CHANGE_FORBIDDEN - 이메일은 변경할 수 없습니다"), ], diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/exception/UserErrorCode.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/exception/UserErrorCode.kt index 348a5835..d89f5bff 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/exception/UserErrorCode.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/exception/UserErrorCode.kt @@ -21,6 +21,6 @@ enum class UserErrorCode( NO_SUCH_USER( httpStatusCode = HttpStatus.NOT_FOUND, title = "존재하지 않는 회원입니다.", - message = "이미 탈퇴 처리가 되었거나\n존재하지 않는 회원입니다." - ) + message = "이미 탈퇴 처리가 되었거나\n존재하지 않는 회원입니다.", + ), } diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/service/UserService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/service/UserService.kt index cf27940e..e1ea9263 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/service/UserService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/service/UserService.kt @@ -59,15 +59,12 @@ class UserService( userRepository.save(user) } - fun deleteMe( - user: User, - ) { + fun deleteMe(user: User) { try { userRepository.delete(user) } catch (e: Exception) { throw UserException(UserErrorCode.NO_SUCH_USER) } - } private fun validateName(name: String) { From 2b4ef3a5c7ecd19ab60e9122114636318c2d398e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=EC=A7=80=EC=88=98?= Date: Thu, 5 Mar 2026 15:37:50 +0900 Subject: [PATCH 05/17] =?UTF-8?q?:sparkle:=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=ED=83=88=ED=87=B4=20api=20=EB=B0=8F=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Conflicts: # src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt --- .../domain/user/controller/UserController.kt | 9 +++ .../domain/user/exception/UserErrorCode.kt | 5 ++ .../domain/user/service/UserService.kt | 80 +++---------------- 3 files changed, 26 insertions(+), 68 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt index 8f214cf1..76b8bd4d 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt @@ -8,6 +8,7 @@ import com.wafflestudio.spring2025.domain.user.dto.PatchMeResponse import com.wafflestudio.spring2025.domain.user.model.User import com.wafflestudio.spring2025.domain.user.service.UserService 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.PatchMapping import org.springframework.web.bind.annotation.RequestBody @@ -41,4 +42,12 @@ class UserController( ) return ResponseEntity.ok(userService.me(user)) } + + @DeleteMapping("/me") + fun deleteMe( + @Parameter(hidden = true) @LoggedInUser user: User, + ): ResponseEntity { + userService.deleteMe(user = user) + return ResponseEntity.ok().build() + } } diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/exception/UserErrorCode.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/exception/UserErrorCode.kt index f0c68bfe..348a5835 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/exception/UserErrorCode.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/exception/UserErrorCode.kt @@ -18,4 +18,9 @@ enum class UserErrorCode( title = "이메일은 변경할 수 없습니다.", message = "이메일 주소는 변경이 불가능합니다.", ), + NO_SUCH_USER( + httpStatusCode = HttpStatus.NOT_FOUND, + title = "존재하지 않는 회원입니다.", + message = "이미 탈퇴 처리가 되었거나\n존재하지 않는 회원입니다." + ) } diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/service/UserService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/service/UserService.kt index 321f1e6e..cf27940e 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/service/UserService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/service/UserService.kt @@ -8,6 +8,7 @@ import com.wafflestudio.spring2025.domain.auth.exception.AuthenticationRequiredE import com.wafflestudio.spring2025.domain.user.dto.core.UserDto import com.wafflestudio.spring2025.domain.user.exception.EmailChangeForbiddenException import com.wafflestudio.spring2025.domain.user.exception.UserErrorCode +import com.wafflestudio.spring2025.domain.user.exception.UserException import com.wafflestudio.spring2025.domain.user.exception.UserValidationException import com.wafflestudio.spring2025.domain.user.model.User import com.wafflestudio.spring2025.domain.user.repository.UserRepository @@ -58,6 +59,17 @@ class UserService( userRepository.save(user) } + fun deleteMe( + user: User, + ) { + try { + userRepository.delete(user) + } catch (e: Exception) { + throw UserException(UserErrorCode.NO_SUCH_USER) + } + + } + private fun validateName(name: String) { if (name.isBlank()) { throw AuthValidationException(AuthErrorCode.BAD_NAME) @@ -89,72 +101,4 @@ class UserService( throw UserValidationException(UserErrorCode.PROFILE_IMAGE_NOT_FOUND) } } - -// -// fun updateProfileImage( -// user: User?, -// image: MultipartFile, -// ) { -// if (user == null) throw AuthenticationRequiredException() -// validateProfileImage(image) -// -// val ext = extractExtension(image.originalFilename) -// val key = "profile-images/${user.id}/${UUID.randomUUID()}$ext" -// -// val putReq = -// PutObjectRequest -// .builder() -// .bucket(s3Props.bucket) -// .key(key) -// .contentType(image.contentType ?: "application/octet-stream") -// .build() -// -// image.inputStream.use { input -> -// s3Client.putObject( -// putReq, -// RequestBody.fromInputStream(input, image.size), -// ) -// } -// -// // DB에는 URL이 아니라 key 저장 -// user.profileImage = key -// userRepository.save(user) -// } -// -// fun deleteProfileImage(user: User?) { -// if (user == null) throw AuthenticationRequiredException() -// -// // (선택) S3에서도 삭제 -// user.profileImage?.let { key -> -// val delReq = -// DeleteObjectRequest -// .builder() -// .bucket(s3Props.bucket) -// .key(key) -// .build() -// runCatching { s3Client.deleteObject(delReq) } -// } -// -// user.profileImage = null -// userRepository.save(user) -// } -// - -// -// private fun validateProfileImage(image: MultipartFile) { -// if (image.isEmpty) throw UserValidationException(UserErrorCode.PROFILE_IMAGE_EMPTY) -// val contentType = image.contentType ?: "" -// if (!contentType.startsWith("image/")) throw UserValidationException(UserErrorCode.PROFILE_IMAGE_FORMAT_INVALID) -// -// val maxSizeBytes = 5L * 1024L * 1024L -// if (image.size > maxSizeBytes) throw UserValidationException(UserErrorCode.PROFILE_IMAGE_TOO_LARGE) -// } -// -// private fun extractExtension(originalFilename: String?): String { -// if (originalFilename.isNullOrBlank()) return ".jpg" -// val idx = originalFilename.lastIndexOf('.') -// if (idx < 0) return ".jpg" -// val ext = originalFilename.substring(idx).lowercase() -// return if (ext in setOf(".jpg", ".jpeg", ".png", ".webp")) ext else ".jpg" -// } } From 22e76a76d3fc7325c756616bffc3e99337801c64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=EC=A7=80=EC=88=98?= Date: Thu, 5 Mar 2026 15:39:46 +0900 Subject: [PATCH 06/17] =?UTF-8?q?:sparkle:=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring2025/domain/user/controller/UserController.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt index 76b8bd4d..4daa941f 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt @@ -48,6 +48,6 @@ class UserController( @Parameter(hidden = true) @LoggedInUser user: User, ): ResponseEntity { userService.deleteMe(user = user) - return ResponseEntity.ok().build() + return ResponseEntity.noContent().build() } } From 66408453abba4657e42463982a40a88cc554c273 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Thu, 5 Mar 2026 19:46:15 +0900 Subject: [PATCH 07/17] :art: ktlint --- .../spring2025/domain/user/exception/UserErrorCode.kt | 4 ++-- .../spring2025/domain/user/service/UserService.kt | 5 +---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/exception/UserErrorCode.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/exception/UserErrorCode.kt index 348a5835..d89f5bff 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/exception/UserErrorCode.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/exception/UserErrorCode.kt @@ -21,6 +21,6 @@ enum class UserErrorCode( NO_SUCH_USER( httpStatusCode = HttpStatus.NOT_FOUND, title = "존재하지 않는 회원입니다.", - message = "이미 탈퇴 처리가 되었거나\n존재하지 않는 회원입니다." - ) + message = "이미 탈퇴 처리가 되었거나\n존재하지 않는 회원입니다.", + ), } diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/service/UserService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/service/UserService.kt index cf27940e..e1ea9263 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/service/UserService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/service/UserService.kt @@ -59,15 +59,12 @@ class UserService( userRepository.save(user) } - fun deleteMe( - user: User, - ) { + fun deleteMe(user: User) { try { userRepository.delete(user) } catch (e: Exception) { throw UserException(UserErrorCode.NO_SUCH_USER) } - } private fun validateName(name: String) { From 104949ec1cf62e68895ff6e2b2f165a3bc38f863 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Fri, 22 May 2026 18:22:31 +0900 Subject: [PATCH 08/17] =?UTF-8?q?swagger=EA=B4=80=EB=A0=A8=20=EC=96=B4?= =?UTF-8?q?=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring2025/domain/user/controller/UserController.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt index 4daa941f..0eb1572c 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/controller/UserController.kt @@ -45,7 +45,7 @@ class UserController( @DeleteMapping("/me") fun deleteMe( - @Parameter(hidden = true) @LoggedInUser user: User, + @LoggedInUser user: User, ): ResponseEntity { userService.deleteMe(user = user) return ResponseEntity.noContent().build() From fb69857bc67b5a90bab74e0339d344b746b21d42 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Tue, 2 Jun 2026 09:43:42 +0900 Subject: [PATCH 09/17] =?UTF-8?q?:card=5Ffile=5Fbox:=20event=20=EC=A3=BC?= =?UTF-8?q?=EC=B5=9C=EC=9E=90=20=EC=B9=BC=EB=9F=BC=20nullable=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring2025/common/email/service/EmailService.kt | 4 ++-- .../spring2025/domain/event/dto/core/EventDto.kt | 2 +- .../domain/event/dto/response/EventDetailResponse.kt | 2 +- .../spring2025/domain/event/model/Event.kt | 2 +- .../spring2025/domain/event/service/EventService.kt | 12 ++++++------ .../V15__make_event_created_by_nullable.sql | 1 + 6 files changed, 12 insertions(+), 11 deletions(-) create mode 100644 src/main/resources/db/migration/V15__make_event_created_by_nullable.sql diff --git a/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt b/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt index e6ce6809..1bac08bd 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/common/email/service/EmailService.kt @@ -109,7 +109,7 @@ class EmailService( val endsAt: Instant?, val location: String?, val description: String?, - val hostEmail: String, + val hostEmail: String?, ) data class DemotionEmailData( @@ -240,7 +240,7 @@ class EmailService( .replace("{eventTitle}", formatEventTitle(data.eventTitle)) .replace("{eventDateRange}", formatEventDateRange(data.startsAt, data.endsAt, "-")) .replace("{location}", formatLocation(data.location)) - .replace("{hostEmail}", data.hostEmail) + .replace("{hostEmail}", data.hostEmail ?: "탈퇴유저") .replace("{description}", formatDescription(data.description)) sendHtmlEmail( diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/core/EventDto.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/core/EventDto.kt index 778ea5ca..6a1a65bd 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/core/EventDto.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/core/EventDto.kt @@ -13,7 +13,7 @@ data class EventDto( val waitlistEnabled: Boolean, val registrationStartsAt: Long?, val registrationEndsAt: Long, - val createdBy: Long, + val createdBy: Long?, val createdAt: Long?, val updatedAt: Long?, ) { diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/EventDetailResponse.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/EventDetailResponse.kt index 685cfd04..a1ab0afd 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/EventDetailResponse.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/dto/response/EventDetailResponse.kt @@ -26,7 +26,7 @@ data class EventInfo( data class CreatorInfo( val name: String, - val email: String, + val email: String?, val profileImage: String?, ) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/model/Event.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/model/Event.kt index 3f635d9d..9779a740 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/model/Event.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/model/Event.kt @@ -28,7 +28,7 @@ class Event( @Column("registration_ends_at") var registrationEndsAt: Instant, @Column("created_by") - var createdBy: Long, + var createdBy: Long?, @CreatedDate @Column("created_at") var createdAt: Instant? = null, diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt index 7d41b3e9..4bdeae75 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt @@ -99,7 +99,7 @@ class EventService( val userIdsToFetch = listOfNotNull(event.createdBy, requesterId).distinct() val usersById = userRepository.findAllById(userIdsToFetch).associateBy { it.id!! } - val creatorUser = usersById[event.createdBy] ?: throw EventNotFoundException() + val creatorUser = event.createdBy?.let { usersById[it] } val myReg = if (requesterId == null) { @@ -230,9 +230,9 @@ class EventService( ), creator = CreatorInfo( - name = creatorUser.name, - email = creatorUser.email, - profileImage = creatorUser.profileImage?.let { imageService.presignedGetUrl(it) }, + name = creatorUser?.name ?: "탈퇴유저", + email = creatorUser?.email, + profileImage = creatorUser?.profileImage?.let { imageService.presignedGetUrl(it) }, ), viewer = viewer, capabilities = capabilities, @@ -391,7 +391,7 @@ class EventService( ) // 이메일 데이터 구성 (삭제 전에 user 정보 조회) - val hostUser = userRepository.findById(event.createdBy).orElse(null) + val hostUser = event.createdBy?.let { userRepository.findById(it).orElse(null) } val userIds = registrationsToNotify.mapNotNull { it.userId }.distinct() val usersById = userRepository.findAllById(userIds).associateBy { it.id!! } @@ -408,7 +408,7 @@ class EventService( endsAt = event.endsAt, location = event.location, description = event.description, - hostEmail = hostUser?.email ?: "", + hostEmail = hostUser?.email, ) } diff --git a/src/main/resources/db/migration/V15__make_event_created_by_nullable.sql b/src/main/resources/db/migration/V15__make_event_created_by_nullable.sql new file mode 100644 index 00000000..2161f421 --- /dev/null +++ b/src/main/resources/db/migration/V15__make_event_created_by_nullable.sql @@ -0,0 +1 @@ +ALTER TABLE events MODIFY COLUMN created_by BIGINT NULL; \ No newline at end of file From b2b4a4f4e912a88eff78b6215e5051ff8545eb7b Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Tue, 2 Jun 2026 09:46:53 +0900 Subject: [PATCH 10/17] =?UTF-8?q?:sparkles:=20=EC=A3=BC=EC=B5=9C=EC=9E=90?= =?UTF-8?q?=EB=B3=84=EB=A1=9C=20event=20=EC=A1=B0=ED=9A=8C=ED=95=98?= =?UTF-8?q?=EB=8A=94=20repository=20=EB=A9=94=EC=86=8C=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spring2025/domain/event/repository/EventRepository.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/repository/EventRepository.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/repository/EventRepository.kt index 04e87e26..a99b0205 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/repository/EventRepository.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/repository/EventRepository.kt @@ -8,6 +8,8 @@ import java.time.Instant interface EventRepository : ListCrudRepository { fun findByPublicId(publicId: String): Event? + fun findByCreatedBy(createdBy: Long): List + fun findByCreatedByAndCreatedAtIsNotNullOrderByCreatedAtDesc( createdBy: Long, pageable: Pageable, From baea13ec7e80b3cbe71866b1c8bd97bc08df6895 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Tue, 2 Jun 2026 10:08:54 +0900 Subject: [PATCH 11/17] =?UTF-8?q?:sparkles:=20=EC=9C=A0=EC=A0=80=EB=B3=84?= =?UTF-8?q?=20=EC=8B=A0=EC=B2=AD=20=EA=B1=B4=20=EC=A1=B0=ED=9A=8C=20/=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EB=A0=88=ED=8F=AC=EC=A7=80=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=20=EB=A9=94=EC=86=8C=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/RegistrationRepository.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/repository/RegistrationRepository.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/repository/RegistrationRepository.kt index 1c91aa06..84e7cae0 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/repository/RegistrationRepository.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/repository/RegistrationRepository.kt @@ -14,6 +14,18 @@ interface RegistrationRepository : fun deleteByEventId(eventId: Long) + fun deleteByEventIdIn(eventIds: Collection) + + fun findByUserIdAndStatusIn( + userId: Long, + statuses: Collection, + ): List + + fun findByEventIdInAndStatusIn( + eventIds: Collection, + statuses: Collection, + ): List + fun findByRegistrationPublicId(registrationPublicId: String): Registration? @Query( From 1a54e76a8bd004fc8cd73938664c48f03d26a75b Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Tue, 2 Jun 2026 10:11:35 +0900 Subject: [PATCH 12/17] =?UTF-8?q?:sparkles:=20=EC=9C=A0=EC=A0=80=EB=B3=84?= =?UTF-8?q?=20=EC=86=8C=EC=85=9C=EA=B3=84=EC=A0=95=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=EC=82=AD=EC=A0=9C=20=EB=A0=88=ED=8F=AC=EC=A7=80?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EB=A9=94=EC=86=8C=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/user/identity/repository/UserIdentityRepository.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/identity/repository/UserIdentityRepository.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/identity/repository/UserIdentityRepository.kt index 5145a2c5..ccb9d6ed 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/identity/repository/UserIdentityRepository.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/identity/repository/UserIdentityRepository.kt @@ -12,4 +12,6 @@ interface UserIdentityRepository : ListCrudRepository { fun findByUserId(userId: Long): List fun findByProviderUserId(providerId: String): List + + fun deleteByUserId(userId: Long) } From aec163a37254eb09ccf35c586db061f2616e49e8 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Tue, 2 Jun 2026 10:36:09 +0900 Subject: [PATCH 13/17] =?UTF-8?q?:sparkles:=20=ED=83=88=ED=87=B4=20?= =?UTF-8?q?=EC=9C=A0=EC=A0=80=EC=9D=98=20=EC=B0=B8=EC=97=AC=EC=8B=A0?= =?UTF-8?q?=EC=B2=AD=EA=B1=B4=20=EC=9D=BC=EA=B4=84=EC=B7=A8=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/RegistrationService.kt | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt index 46ea8c70..12e7309b 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/registration/service/RegistrationService.kt @@ -281,6 +281,35 @@ RegistrationService( } } + /** + * 유저 탈퇴 시 호출. 신청기간/소유권 체크 없이 다수 등록을 일괄 삭제하고 + * 확정 등록이 있던 이벤트에 대해서만 대기자 승격을 트리거한다. + * 탈퇴자에게 취소 메일은 발송하지 않는다. 대기자 승격 메일은 reconcileWaitlist 내부에서 처리됨. + */ + @Transactional + fun cancelRegistrationsForWithdrawal(registrations: List) { + if (registrations.isEmpty()) return + + val byEventId = registrations.groupBy { it.eventId } + val eventIds = byEventId.keys.sorted() + + // 1. 이벤트 락 (ID 오름차순으로 deadlock 방지) + eventIds.forEach { eventLockRepository.lockById(it) } + + // 2. CONFIRMED 등록이 포함된 이벤트만 대기자 승격 필요 + val eventsToReconcile = + byEventId + .filterValues { regs -> regs.any { it.status == RegistrationStatus.CONFIRMED } } + .keys + .sorted() + + // 3. 배치 삭제 + registrationRepository.deleteAllById(registrations.map { it.id!! }) + + // 4. 필요한 이벤트만 승격 + eventsToReconcile.forEach { reconcileWaitlist(it) } + } + private fun toResponseStatus(status: RegistrationStatus): RegistrationStatusResponse = when (status) { RegistrationStatus.HOST -> RegistrationStatusResponse.CONFIRMED From f46b827c7c594b671d3a66c4f8475c979857927f Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Tue, 2 Jun 2026 10:36:47 +0900 Subject: [PATCH 14/17] =?UTF-8?q?:sparkles:=20=ED=83=88=ED=87=B4=20?= =?UTF-8?q?=EC=9C=A0=EC=A0=80=EC=9D=98=20=EC=A3=BC=EC=B5=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=EC=9D=BC=EA=B4=84=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/event/service/EventService.kt | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt index 4bdeae75..7826de9b 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/event/service/EventService.kt @@ -423,6 +423,54 @@ class EventService( } } + /** + * 유저 탈퇴 시 호출. 시작 전(now < startsAt) 이벤트들을 일괄 삭제하고 + * 참여자/대기자에게 익명화된 주최자 정보로 취소 메일을 발송한다. + * 소유권 체크는 호출 측에서 보장. + */ + @Transactional + fun deleteEventsForWithdrawal(events: List) { + if (events.isEmpty()) return + val eventIds = events.map { it.id!! }.sorted() + + // 락 획득 (ID 오름차순으로 deadlock 방지) + eventIds.forEach { eventLockRepository.lockById(it) } + + val eventsById = events.associateBy { it.id!! } + val registrations = + registrationRepository.findByEventIdInAndStatusIn( + eventIds = eventIds, + statuses = listOf(RegistrationStatus.CONFIRMED, RegistrationStatus.WAITLISTED), + ) + val userIds = registrations.mapNotNull { it.userId }.distinct() + val usersById = userRepository.findAllById(userIds).associateBy { it.id!! } + + val emailDataList = + registrations.mapNotNull { reg -> + val event = eventsById[reg.eventId] ?: return@mapNotNull null + val user = reg.userId?.let { usersById[it] } + val toEmail = user?.email ?: reg.guestEmail + if (toEmail.isNullOrBlank()) return@mapNotNull null + EmailService.EventCancellationEmailData( + toEmail = toEmail, + name = user?.name ?: reg.guestName ?: "참여자", + eventTitle = event.title, + startsAt = event.startsAt, + endsAt = event.endsAt, + location = event.location, + description = event.description, + hostEmail = null, + ) + } + + registrationRepository.deleteByEventIdIn(eventIds) + eventRepository.deleteAllById(eventIds) + + afterCommit { + emailDataList.forEach { emailService.sendEventCancellationEmail(it) } + } + } + private fun getEventByPublicId(publicId: String): Event = eventRepository.findByPublicId(publicId) ?: throw EventNotFoundException() From 41e3a7acc89661b6e3e1626349f66fcc8af1b9d6 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Tue, 2 Jun 2026 10:38:43 +0900 Subject: [PATCH 15/17] =?UTF-8?q?:sparkles:=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=ED=83=88=ED=87=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/user/service/UserService.kt | 93 ++++++++++++++++++- 1 file changed, 89 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/service/UserService.kt b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/service/UserService.kt index e1ea9263..0433c204 100644 --- a/src/main/kotlin/com/wafflestudio/spring2025/domain/user/service/UserService.kt +++ b/src/main/kotlin/com/wafflestudio/spring2025/domain/user/service/UserService.kt @@ -5,18 +5,26 @@ import com.wafflestudio.spring2025.config.AwsS3Properties import com.wafflestudio.spring2025.domain.auth.exception.AuthErrorCode import com.wafflestudio.spring2025.domain.auth.exception.AuthValidationException import com.wafflestudio.spring2025.domain.auth.exception.AuthenticationRequiredException +import com.wafflestudio.spring2025.domain.event.repository.EventRepository +import com.wafflestudio.spring2025.domain.event.service.EventService +import com.wafflestudio.spring2025.domain.registration.model.RegistrationStatus +import com.wafflestudio.spring2025.domain.registration.repository.RegistrationRepository +import com.wafflestudio.spring2025.domain.registration.service.RegistrationService import com.wafflestudio.spring2025.domain.user.dto.core.UserDto import com.wafflestudio.spring2025.domain.user.exception.EmailChangeForbiddenException import com.wafflestudio.spring2025.domain.user.exception.UserErrorCode import com.wafflestudio.spring2025.domain.user.exception.UserException import com.wafflestudio.spring2025.domain.user.exception.UserValidationException +import com.wafflestudio.spring2025.domain.user.identity.repository.UserIdentityRepository import com.wafflestudio.spring2025.domain.user.model.User import com.wafflestudio.spring2025.domain.user.repository.UserRepository import org.mindrot.jbcrypt.BCrypt import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional import software.amazon.awssdk.services.s3.S3Client import software.amazon.awssdk.services.s3.model.HeadObjectRequest import software.amazon.awssdk.services.s3.model.NoSuchKeyException +import java.time.Instant @Service class UserService( @@ -24,6 +32,11 @@ class UserService( private val s3Client: S3Client, private val s3Props: AwsS3Properties, private val imageService: ImageService, + private val eventRepository: EventRepository, + private val registrationRepository: RegistrationRepository, + private val userIdentityRepository: UserIdentityRepository, + private val eventService: EventService, + private val registrationService: RegistrationService, ) { fun me(user: User?): UserDto { if (user == null) throw AuthenticationRequiredException() @@ -59,12 +72,84 @@ class UserService( userRepository.save(user) } + @Transactional fun deleteMe(user: User) { - try { - userRepository.delete(user) - } catch (e: Exception) { - throw UserException(UserErrorCode.NO_SUCH_USER) + val now = Instant.now() + val userId = user.id ?: throw UserException(UserErrorCode.NO_SUCH_USER) + + // 1. 주최 이벤트 처리 + val hostedEvents = eventRepository.findByCreatedBy(userId) + val (startedEvents, notStartedEvents) = + hostedEvents.partition { it.startsAt != null && !now.isBefore(it.startsAt) } + + // 시작된 이벤트: Event + HOST 등록 익명화 + if (startedEvents.isNotEmpty()) { + val startedIds = startedEvents.map { it.id!! } + startedEvents.forEach { it.createdBy = null } + eventRepository.saveAll(startedEvents) + + val hostRegs = + registrationRepository.findByEventIdInAndStatusIn( + eventIds = startedIds, + statuses = listOf(RegistrationStatus.HOST), + ) + hostRegs.forEach { + it.userId = null + it.guestName = "탈퇴유저" + it.guestEmail = null + } + registrationRepository.saveAll(hostRegs) } + + // 시작 전 이벤트: 일괄 삭제 + 취소 메일 (락은 EventService 내부에서 획득) + eventService.deleteEventsForWithdrawal(notStartedEvents) + + // 2. 참여 등록 처리 (CONFIRMED + WAITLISTED) + val regs = + registrationRepository.findByUserIdAndStatusIn( + userId = userId, + statuses = listOf(RegistrationStatus.CONFIRMED, RegistrationStatus.WAITLISTED), + ) + if (regs.isNotEmpty()) { + val eventsById = + eventRepository + .findAllById(regs.map { it.eventId }.distinct()) + .associateBy { it.id!! } + // 고아 등록(이벤트 없음)은 skip + val valid = regs.filter { eventsById.containsKey(it.eventId) } + val (toAnonymize, toCancel) = + valid.partition { reg -> !now.isBefore(eventsById[reg.eventId]!!.registrationEndsAt) } + + // 신청기간 마감: 익명화 + toAnonymize.forEach { + it.userId = null + it.guestName = "탈퇴유저" + it.guestEmail = null + } + registrationRepository.saveAll(toAnonymize) + + // 신청기간 내: 일괄 취소 + 대기자 승격 + registrationService.cancelRegistrationsForWithdrawal(toCancel) + } + + // 2b. BANNED 등록 익명화 (FK 정리) + val bannedRegs = + registrationRepository.findByUserIdAndStatusIn( + userId = userId, + statuses = listOf(RegistrationStatus.BANNED), + ) + bannedRegs.forEach { + it.userId = null + it.guestName = "탈퇴유저" + it.guestEmail = null + } + registrationRepository.saveAll(bannedRegs) + + // 3. UserIdentity 삭제 + userIdentityRepository.deleteByUserId(userId) + + // 4. User 삭제 + userRepository.delete(user) } private fun validateName(name: String) { From 69190b80a8d724a26d51c1131451af009eb6746a Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Tue, 2 Jun 2026 10:38:52 +0900 Subject: [PATCH 16/17] =?UTF-8?q?:sparkles:=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=ED=83=88=ED=87=B4=20=EA=B4=80=EB=A0=A8=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../wafflestudio/spring2025/DataGenerator.kt | 49 +++++ .../spring2025/UserIntegrationTest.kt | 172 ++++++++++++++++++ 2 files changed, 221 insertions(+) diff --git a/src/test/kotlin/com/wafflestudio/spring2025/DataGenerator.kt b/src/test/kotlin/com/wafflestudio/spring2025/DataGenerator.kt index 5e6313aa..d6000945 100644 --- a/src/test/kotlin/com/wafflestudio/spring2025/DataGenerator.kt +++ b/src/test/kotlin/com/wafflestudio/spring2025/DataGenerator.kt @@ -1,16 +1,24 @@ package com.wafflestudio.spring2025 import com.wafflestudio.spring2025.domain.auth.JwtTokenProvider +import com.wafflestudio.spring2025.domain.event.model.Event +import com.wafflestudio.spring2025.domain.event.repository.EventRepository +import com.wafflestudio.spring2025.domain.registration.model.Registration +import com.wafflestudio.spring2025.domain.registration.model.RegistrationStatus +import com.wafflestudio.spring2025.domain.registration.repository.RegistrationRepository import com.wafflestudio.spring2025.domain.user.model.User import com.wafflestudio.spring2025.domain.user.repository.UserRepository import org.mindrot.jbcrypt.BCrypt import org.springframework.stereotype.Component +import java.time.Instant import java.util.UUID @Component class DataGenerator( private val userRepository: UserRepository, private val jwtTokenProvider: JwtTokenProvider, + private val eventRepository: EventRepository, + private val registrationRepository: RegistrationRepository, ) { fun generateUser(): Pair { val email = "user-${UUID.randomUUID()}@example.com" @@ -54,4 +62,45 @@ class DataGenerator( val token = jwtTokenProvider.createToken(user.id!!) return user to token } + + fun createEvent( + createdBy: Long, + title: String = "테스트 이벤트", + capacity: Int = 10, + waitlistEnabled: Boolean = true, + startsAt: Instant = Instant.now().plusSeconds(7200), + endsAt: Instant = Instant.now().plusSeconds(10800), + registrationStartsAt: Instant? = null, + registrationEndsAt: Instant = Instant.now().plusSeconds(5400), + ): Event = + eventRepository.save( + Event( + publicId = UUID.randomUUID().toString(), + title = title, + capacity = capacity, + waitlistEnabled = waitlistEnabled, + startsAt = startsAt, + endsAt = endsAt, + registrationStartsAt = registrationStartsAt, + registrationEndsAt = registrationEndsAt, + createdBy = createdBy, + ), + ) + + fun createRegistration( + eventId: Long, + userId: Long? = null, + guestName: String? = null, + guestEmail: String? = null, + status: RegistrationStatus = RegistrationStatus.CONFIRMED, + ): Registration = + registrationRepository.save( + Registration( + userId = userId, + eventId = eventId, + guestName = guestName, + guestEmail = guestEmail, + status = status, + ), + ) } diff --git a/src/test/kotlin/com/wafflestudio/spring2025/UserIntegrationTest.kt b/src/test/kotlin/com/wafflestudio/spring2025/UserIntegrationTest.kt index dbe38e39..e7d2aa93 100644 --- a/src/test/kotlin/com/wafflestudio/spring2025/UserIntegrationTest.kt +++ b/src/test/kotlin/com/wafflestudio/spring2025/UserIntegrationTest.kt @@ -1,12 +1,21 @@ package com.wafflestudio.spring2025 import com.fasterxml.jackson.databind.ObjectMapper +import com.wafflestudio.spring2025.common.email.service.EmailService import com.wafflestudio.spring2025.common.image.service.ImageService import com.wafflestudio.spring2025.domain.auth.dto.LoginRequest +import com.wafflestudio.spring2025.domain.event.repository.EventRepository +import com.wafflestudio.spring2025.domain.registration.model.RegistrationStatus +import com.wafflestudio.spring2025.domain.registration.repository.RegistrationRepository import com.wafflestudio.spring2025.domain.user.dto.PatchMeRequest +import com.wafflestudio.spring2025.domain.user.repository.UserRepository +import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test import org.mockito.ArgumentMatchers.any import org.mockito.BDDMockito.given +import org.mockito.kotlin.argThat +import org.mockito.kotlin.times +import org.mockito.kotlin.verify import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc import org.springframework.boot.test.context.SpringBootTest @@ -15,6 +24,7 @@ import org.springframework.http.MediaType import org.springframework.test.context.ActiveProfiles import org.springframework.test.context.bean.override.mockito.MockitoBean import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post @@ -24,6 +34,7 @@ import org.testcontainers.junit.jupiter.Testcontainers import software.amazon.awssdk.services.s3.S3Client import software.amazon.awssdk.services.s3.model.HeadObjectRequest import software.amazon.awssdk.services.s3.model.NoSuchKeyException +import java.time.Instant @SpringBootTest @ActiveProfiles("test") @@ -36,6 +47,9 @@ class UserIntegrationTest private val mvc: MockMvc, private val mapper: ObjectMapper, private val dataGenerator: DataGenerator, + private val eventRepository: EventRepository, + private val registrationRepository: RegistrationRepository, + private val userRepository: UserRepository, ) { // UserService.validateProfileImage 에서 직접 호출하는 S3Client 모킹 @MockitoBean @@ -45,6 +59,10 @@ class UserIntegrationTest @MockitoBean private lateinit var imageService: ImageService + // 탈퇴 시나리오의 메일 호출 검증용 + @MockitoBean + private lateinit var emailService: EmailService + // ================================================================= // GET /api/users/me — 내 정보 조회 // ================================================================= @@ -336,4 +354,158 @@ class UserIntegrationTest ).andExpect(status().isUnauthorized) .andExpect(jsonPath("$.code").value("AUTHENTICATION_REQUIRED")) } + + // ================================================================= + // DELETE /api/users/me — 회원 탈퇴 + // ================================================================= + + @Test + fun `시작된 이벤트를 주최한 유저가 탈퇴하면 이벤트는 유지되고 createdBy가 null이 된다`() { + val (host, token) = dataGenerator.generateUser() + // 이미 시작된 이벤트 (startsAt 과거, registrationEndsAt 과거) + val event = + dataGenerator.createEvent( + createdBy = host.id!!, + startsAt = Instant.now().minusSeconds(3600), + endsAt = Instant.now().plusSeconds(3600), + registrationStartsAt = Instant.now().minusSeconds(7200), + registrationEndsAt = Instant.now().minusSeconds(60), + ) + + mvc + .perform( + delete("/api/users/me") + .header("Authorization", "Bearer $token"), + ).andExpect(status().isNoContent) + + val anonymized = eventRepository.findById(event.id!!).orElseThrow() + assertThat(anonymized.createdBy).isNull() + assertThat(userRepository.findById(host.id!!)).isEmpty + } + + @Test + fun `시작 전 이벤트를 주최한 유저가 탈퇴하면 이벤트와 등록이 삭제되고 참여자에게 취소 메일이 발송된다`() { + val (host, token) = dataGenerator.generateUser() + val (participant, _) = dataGenerator.generateUser() + val event = + dataGenerator.createEvent( + createdBy = host.id!!, + startsAt = Instant.now().plusSeconds(7200), + ) + val registration = + dataGenerator.createRegistration( + eventId = event.id!!, + userId = participant.id!!, + status = RegistrationStatus.CONFIRMED, + ) + + mvc + .perform( + delete("/api/users/me") + .header("Authorization", "Bearer $token"), + ).andExpect(status().isNoContent) + + assertThat(eventRepository.findById(event.id!!)).isEmpty + assertThat(registrationRepository.findById(registration.id!!)).isEmpty + + verify(emailService, times(1)).sendEventCancellationEmail( + argThat { data -> + data.toEmail == participant.email && data.hostEmail == null + }, + ) + } + + @Test + fun `신청기간이 마감된 모임에 등록한 유저가 탈퇴하면 등록이 익명화된다`() { + val (host, _) = dataGenerator.generateUser() + val (participant, token) = dataGenerator.generateUser() + // 신청기간 마감, 이벤트는 아직 안 시작 + val event = + dataGenerator.createEvent( + createdBy = host.id!!, + startsAt = Instant.now().plusSeconds(3600), + endsAt = Instant.now().plusSeconds(7200), + registrationStartsAt = Instant.now().minusSeconds(7200), + registrationEndsAt = Instant.now().minusSeconds(60), + ) + val registration = + dataGenerator.createRegistration( + eventId = event.id!!, + userId = participant.id!!, + status = RegistrationStatus.CONFIRMED, + ) + + mvc + .perform( + delete("/api/users/me") + .header("Authorization", "Bearer $token"), + ).andExpect(status().isNoContent) + + val anonymized = registrationRepository.findById(registration.id!!).orElseThrow() + assertThat(anonymized.userId).isNull() + assertThat(anonymized.guestName).isEqualTo("탈퇴유저") + assertThat(anonymized.guestEmail).isNull() + } + + @Test + fun `신청기간 내 모임에 CONFIRMED 등록한 유저가 탈퇴하면 등록이 삭제되고 대기자가 승격된다`() { + val (host, _) = dataGenerator.generateUser() + val (withdrawingUser, token) = dataGenerator.generateUser() + val (waitlisted, _) = dataGenerator.generateUser() + // 정원 1, 대기 허용. 탈퇴 유저는 CONFIRMED, 다른 유저는 WAITLISTED + val event = + dataGenerator.createEvent( + createdBy = host.id!!, + capacity = 1, + waitlistEnabled = true, + ) + val withdrawingReg = + dataGenerator.createRegistration( + eventId = event.id!!, + userId = withdrawingUser.id!!, + status = RegistrationStatus.CONFIRMED, + ) + val waitlistedReg = + dataGenerator.createRegistration( + eventId = event.id!!, + userId = waitlisted.id!!, + status = RegistrationStatus.WAITLISTED, + ) + + mvc + .perform( + delete("/api/users/me") + .header("Authorization", "Bearer $token"), + ).andExpect(status().isNoContent) + + assertThat(registrationRepository.findById(withdrawingReg.id!!)).isEmpty + val promoted = registrationRepository.findById(waitlistedReg.id!!).orElseThrow() + assertThat(promoted.status).isEqualTo(RegistrationStatus.CONFIRMED) + assertThat(promoted.userId).isEqualTo(waitlisted.id) + } + + @Test + fun `BANNED 등록만 있는 유저도 정상 탈퇴되고 BANNED 등록이 익명화된다`() { + val (host, _) = dataGenerator.generateUser() + val (bannedUser, token) = dataGenerator.generateUser() + val event = dataGenerator.createEvent(createdBy = host.id!!) + val bannedReg = + dataGenerator.createRegistration( + eventId = event.id!!, + userId = bannedUser.id!!, + status = RegistrationStatus.BANNED, + ) + + mvc + .perform( + delete("/api/users/me") + .header("Authorization", "Bearer $token"), + ).andExpect(status().isNoContent) + + assertThat(userRepository.findById(bannedUser.id!!)).isEmpty + val anonymized = registrationRepository.findById(bannedReg.id!!).orElseThrow() + assertThat(anonymized.userId).isNull() + assertThat(anonymized.status).isEqualTo(RegistrationStatus.BANNED) + assertThat(anonymized.guestName).isEqualTo("탈퇴유저") + } } From 9d8949ac54768825870ea6295d5c80ec65a22f70 Mon Sep 17 00:00:00 2001 From: Jisu Hong Date: Tue, 2 Jun 2026 10:39:12 +0900 Subject: [PATCH 17/17] =?UTF-8?q?:sparkles:=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=ED=83=88=ED=87=B4=20=EA=B4=80=EB=A0=A8=20API=20=EB=8F=84?= =?UTF-8?q?=ED=81=90=EB=A9=98=ED=85=8C=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/static/docs/openapi.yaml | 30 ++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/src/main/resources/static/docs/openapi.yaml b/src/main/resources/static/docs/openapi.yaml index 9f62c069..6c70d35d 100644 --- a/src/main/resources/static/docs/openapi.yaml +++ b/src/main/resources/static/docs/openapi.yaml @@ -393,6 +393,30 @@ paths: $ref: "#/components/schemas/UserDto" "401": description: 인증 필요 + delete: + tags: + - User + summary: 회원 탈퇴 + description: | + 로그인한 사용자를 탈퇴 처리합니다. 도메인 처리는 다음과 같이 분기됩니다. + + - 주최한 모임 + - 이미 시작된 모임 (now >= startsAt): 모임은 유지되고 주최자 정보만 익명화됩니다 (createdBy=null, 응답상 이름은 "탈퇴유저", 이메일은 null). + - 시작 전 모임 (now < startsAt): 모임이 삭제되고 참여자/대기자에게 취소 메일이 발송됩니다. 메일 내 주최자 이메일도 익명화됩니다. + - 참여한 모임 (확정 + 대기) + - 신청기간이 마감된 모임 (now >= registrationEndsAt): 등록 기록은 유지되되 본인 정보가 익명화됩니다. + - 신청기간이 마감되지 않은 모임 (now < registrationEndsAt): 등록이 자동 취소되며, 필요 시 대기자 승격이 발생합니다. + security: + - bearerAuth: [] + responses: + "204": + description: 탈퇴 완료 + "401": + description: 인증 실패 + content: + '*/*': + schema: + $ref: "#/components/schemas/ErrorResponse" /api/registrations/{registrationId}: get: security: [] @@ -945,17 +969,17 @@ components: description: 가능한 동작 블록 CreatorInfo: required: - - email - name type: object properties: name: type: string - description: 이름 + description: 이름 (탈퇴한 주최자는 "탈퇴유저"로 표시됨) example: 홍길동 email: type: string - description: 이메일 + description: 이메일 (탈퇴한 주최자의 경우 null) + nullable: true example: user@example.com profileImage: type: string