diff --git a/docs/account-deletion-api.md b/docs/account-deletion-api.md new file mode 100644 index 00000000..deabccb2 --- /dev/null +++ b/docs/account-deletion-api.md @@ -0,0 +1,180 @@ +# Account Deletion API + +Frontend integration guide for deleting an OnTime account with optional withdrawal feedback. + +## Summary + +Account deletion hard-deletes the user from OnTime. The request can optionally include feedback. If feedback is provided, the backend stores it separately from the `User` table so it remains available after the account is deleted. + +For Google and Apple social accounts, the backend first tries to revoke the social login token, then deletes the local OnTime account. + +## Authentication + +All endpoints require the current OnTime access token. + +```http +Authorization: Bearer {accessToken} +Content-Type: application/json +``` + +## Request Body + +The request body is optional for every deletion endpoint. + +```json +{ + "feedbackId": "d784cde3-9ff9-4054-872a-500bbcc2198a", + "message": "I do not use the app anymore." +} +``` + +Fields: + +| Field | Type | Required | Notes | +| --- | --- | --- | --- | +| `feedbackId` | UUID string | No | Client-generated ID. If omitted, the backend generates one. | +| `message` | string | No | If missing, blank, or only whitespace, feedback is not saved and deletion still proceeds. | + +## General Account Deletion + +Use this endpoint for normal OnTime account deletion. + +```http +DELETE /users/me/delete +``` + +Example without feedback: + +```http +DELETE /users/me/delete +Authorization: Bearer {accessToken} +Content-Type: application/json + +{} +``` + +Example with feedback: + +```http +DELETE /users/me/delete +Authorization: Bearer {accessToken} +Content-Type: application/json + +{ + "feedbackId": "d784cde3-9ff9-4054-872a-500bbcc2198a", + "message": "The notifications were not useful for me." +} +``` + +Success response: + +```json +{ + "status": "success", + "code": "200", + "message": "계정이 성공적으로 삭제되었습니다!", + "data": null +} +``` + +## Google Account Deletion + +Use this endpoint when the current account is linked through Google login. + +```http +DELETE /oauth2/google/me +``` + +Example: + +```http +DELETE /oauth2/google/me +Authorization: Bearer {accessToken} +Content-Type: application/json + +{ + "message": "I am switching to another calendar app." +} +``` + +Success response: + +```text +구글 로그인 회원탈퇴 성공 +``` + +Behavior: + +- Revokes the stored Google OAuth token for OnTime. +- Deletes the local OnTime account. +- Saves optional feedback if `message` is nonblank. +- Does not delete the user's actual Google account. + +Expected frontend result after successful revoke: + +- If the user signs up or logs in with the same Google account again, Google may show an "OnTime에 다시 로그인하는 중입니다" confirmation screen. +- The Google account can still be preselected because the user is still signed in to Google on the device/browser. + +Important caveat: + +- The Google unlink only works if the backend has a valid Google refresh/access token saved in `socialLoginToken`. +- If the client never provided a real Google refresh token, Google revoke may fail and the endpoint may return an error before local deletion. + +## Apple Account Deletion + +Use this endpoint when the current account is linked through Apple login. + +```http +DELETE /oauth2/apple/me +``` + +Example: + +```http +DELETE /oauth2/apple/me +Authorization: Bearer {accessToken} +Content-Type: application/json + +{ + "feedbackId": "85fc54e0-e6c7-4c7e-9312-7784a52bf120", + "message": "I want to restart with a fresh account." +} +``` + +Success response: + +```text +애플 로그인 회원탈퇴 성공 +``` + +Behavior: + +- Revokes the stored Apple OAuth token for OnTime. +- Deletes the local OnTime account. +- Saves optional feedback if `message` is nonblank. +- Does not delete the user's Apple ID. + +## What Gets Stored For Feedback + +When feedback is provided, the backend stores: + +| Stored Field | Notes | +| --- | --- | +| `feedbackId` | Client-provided UUID or backend-generated UUID | +| `deletedUserId` | Previous OnTime user ID | +| `socialType` | `GOOGLE`, `APPLE`, or null for non-social accounts | +| `emailHash` | SHA-256 hash of the normalized email, not plaintext email | +| `message` | User feedback text | +| `createdAt` | Server timestamp | + +Feedback is not linked by foreign key to the deleted user. + +## Frontend Recommendations + +- Treat feedback as optional. Do not block deletion if the user skips it. +- Generate a UUID for `feedbackId` if convenient, but it is safe to omit. +- Use `/oauth2/google/me` for Google-linked accounts if the product requirement is to unlink Google access. +- Use `/oauth2/apple/me` for Apple-linked accounts if the product requirement is to unlink Apple access. +- Use `/users/me/delete` for normal local deletion. +- After a successful deletion response, clear local auth state and navigate to the logged-out screen. +- Do not retry automatically on social revoke errors without showing the user, because the local account may not have been deleted. diff --git a/ontime-back/src/main/java/devkor/ontime_back/controller/SocialAuthController.java b/ontime-back/src/main/java/devkor/ontime_back/controller/SocialAuthController.java index 7366a8c2..163ae182 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/controller/SocialAuthController.java +++ b/ontime-back/src/main/java/devkor/ontime_back/controller/SocialAuthController.java @@ -1,5 +1,6 @@ package devkor.ontime_back.controller; +import devkor.ontime_back.dto.FeedbackAddDto; import devkor.ontime_back.dto.OAuthAppleRequestDto; import devkor.ontime_back.dto.OAuthGoogleUserDto; import devkor.ontime_back.dto.OAuthKakaoUserDto; @@ -112,11 +113,11 @@ public String appleRegisterOrLogin(@RequestBody OAuthAppleRequestDto appleLoginR summary = "애플 소셜 로그인 회원탈퇴" ) @DeleteMapping("/apple/me") - public String appleDeleteUser(HttpServletRequest request, HttpServletResponse response) throws Exception { + public String appleDeleteUser(HttpServletRequest request, HttpServletResponse response, @RequestBody(required = false) FeedbackAddDto feedbackAddDto) throws Exception { Long userId = userAuthService.getUserIdFromToken(request); log.info("userId: {}", userId); appleLoginService.revokeToken(userId); - userAuthService.deleteUser(userId); + userAuthService.deleteUser(userId, feedbackAddDto); return "애플 로그인 회원탈퇴 성공"; } @@ -124,12 +125,12 @@ public String appleDeleteUser(HttpServletRequest request, HttpServletResponse re summary = "구글 소셜 로그인 회원탈퇴" ) @DeleteMapping("/google/me") - public String googleDeleteUser(HttpServletRequest request, HttpServletResponse response) throws Exception { + public String googleDeleteUser(HttpServletRequest request, HttpServletResponse response, @RequestBody(required = false) FeedbackAddDto feedbackAddDto) throws Exception { Long userId = userAuthService.getUserIdFromToken(request); log.info("userId: {}", userId); googleLoginService.revokeToken(userId); - userAuthService.deleteUser(userId); - return "애플 로그인 회원탈퇴 성공"; + userAuthService.deleteUser(userId, feedbackAddDto); + return "구글 로그인 회원탈퇴 성공"; } diff --git a/ontime-back/src/main/java/devkor/ontime_back/controller/UserAuthController.java b/ontime-back/src/main/java/devkor/ontime_back/controller/UserAuthController.java index dd4e5f4d..13c4185b 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/controller/UserAuthController.java +++ b/ontime-back/src/main/java/devkor/ontime_back/controller/UserAuthController.java @@ -1,6 +1,7 @@ package devkor.ontime_back.controller; import devkor.ontime_back.dto.ChangePasswordDto; +import devkor.ontime_back.dto.FeedbackAddDto; import devkor.ontime_back.dto.UserInfoResponse; import devkor.ontime_back.dto.UserSignUpDto; import devkor.ontime_back.entity.User; @@ -107,11 +108,11 @@ public ResponseEntity> changePassword(HttpServletRequest @Operation( summary = "계정 삭제 (User 데이터 하드 삭제)", requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( - description = "계정 삭제 요청 JSON 데이터는 없음. 헤더에 토큰만 있으면 됨", + description = "계정 삭제 요청 JSON 데이터는 선택사항. 탈퇴 피드백을 남기려면 feedbackId, message를 전달", content = @Content( schema = @Schema( type = "object", - example = "{}" + example = "{\"feedbackId\": \"d784cde3-9ff9-4054-872a-500bbcc2198a\", \"message\": \"탈퇴 피드백입니다.\"}" ) ) ) @@ -126,9 +127,9 @@ public ResponseEntity> changePassword(HttpServletRequest @ApiResponse(responseCode = "4XX", description = "계정 삭제 실패", content = @Content(mediaType = "application/json", schema = @Schema(example = "실패 메세지(토큰 오류 제외 비즈니스 로직 오류는 없음)"))) }) @DeleteMapping("/users/me/delete") - public ResponseEntity> deleteUser(HttpServletRequest request) { + public ResponseEntity> deleteUser(HttpServletRequest request, @RequestBody(required = false) FeedbackAddDto feedbackAddDto) { Long userId = userAuthService.getUserIdFromToken(request); - userAuthService.deleteUser(userId); + userAuthService.deleteUser(userId, feedbackAddDto); String message = "계정이 성공적으로 삭제되었습니다!"; return ResponseEntity.ok(ApiResponseForm.success(null, message)); } diff --git a/ontime-back/src/main/java/devkor/ontime_back/dto/FeedbackAddDto.java b/ontime-back/src/main/java/devkor/ontime_back/dto/FeedbackAddDto.java index 86858295..b653711a 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/dto/FeedbackAddDto.java +++ b/ontime-back/src/main/java/devkor/ontime_back/dto/FeedbackAddDto.java @@ -1,12 +1,14 @@ package devkor.ontime_back.dto; -import lombok.Getter; -import lombok.ToString; +import lombok.*; import java.util.UUID; @ToString @Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor public class FeedbackAddDto { private UUID feedbackId; private String message; diff --git a/ontime-back/src/main/java/devkor/ontime_back/entity/AccountDeletionFeedback.java b/ontime-back/src/main/java/devkor/ontime_back/entity/AccountDeletionFeedback.java new file mode 100644 index 00000000..d21d9a64 --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/entity/AccountDeletionFeedback.java @@ -0,0 +1,32 @@ +package devkor.ontime_back.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class AccountDeletionFeedback { + + @Id + private UUID feedbackId; + + private Long deletedUserId; + + @Enumerated(EnumType.STRING) + private SocialType socialType; + + @Column(length = 64) + private String emailHash; + + @Lob + @Column(columnDefinition = "TEXT") + private String message; + + private LocalDateTime createdAt; +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/repository/AccountDeletionFeedbackRepository.java b/ontime-back/src/main/java/devkor/ontime_back/repository/AccountDeletionFeedbackRepository.java new file mode 100644 index 00000000..4683bb90 --- /dev/null +++ b/ontime-back/src/main/java/devkor/ontime_back/repository/AccountDeletionFeedbackRepository.java @@ -0,0 +1,11 @@ +package devkor.ontime_back.repository; + +import devkor.ontime_back.entity.AccountDeletionFeedback; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.UUID; + +@Repository +public interface AccountDeletionFeedbackRepository extends JpaRepository { +} diff --git a/ontime-back/src/main/java/devkor/ontime_back/service/UserAuthService.java b/ontime-back/src/main/java/devkor/ontime_back/service/UserAuthService.java index 9bb7d3b2..7a47fb77 100644 --- a/ontime-back/src/main/java/devkor/ontime_back/service/UserAuthService.java +++ b/ontime-back/src/main/java/devkor/ontime_back/service/UserAuthService.java @@ -1,13 +1,16 @@ package devkor.ontime_back.service; import devkor.ontime_back.dto.ChangePasswordDto; +import devkor.ontime_back.dto.FeedbackAddDto; import devkor.ontime_back.dto.UserAdditionalInfoDto; import devkor.ontime_back.dto.UserInfoResponse; import devkor.ontime_back.dto.UserSignUpDto; +import devkor.ontime_back.entity.AccountDeletionFeedback; import devkor.ontime_back.entity.Role; import devkor.ontime_back.entity.User; import devkor.ontime_back.entity.UserAlarmSetting; import devkor.ontime_back.entity.UserSetting; +import devkor.ontime_back.repository.AccountDeletionFeedbackRepository; import devkor.ontime_back.global.jwt.JwtTokenProvider; import devkor.ontime_back.repository.UserAlarmSettingRepository; import devkor.ontime_back.repository.UserRepository; @@ -23,6 +26,10 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; import java.util.NoSuchElementException; import java.util.UUID; @@ -35,6 +42,7 @@ public class UserAuthService { private final UserRepository userRepository; private final UserSettingRepository userSettingRepository; private final UserAlarmSettingRepository userAlarmSettingRepository; + private final AccountDeletionFeedbackRepository accountDeletionFeedbackRepository; private final PasswordEncoder passwordEncoder; private final JwtTokenProvider jwtTokenProvider; @@ -161,12 +169,55 @@ public User changePassword(Long userId, ChangePasswordDto changePasswordDto) { @Transactional public Long deleteUser(Long userId) { + return deleteUser(userId, null); + } + + @Transactional + public Long deleteUser(Long userId, FeedbackAddDto feedbackAddDto) { User user = userRepository.findById(userId) .orElseThrow(() -> new IllegalArgumentException("유저를 찾을 수 없습니다.")); + saveAccountDeletionFeedback(user, feedbackAddDto); userRepository.delete(user); return userId; } + private void saveAccountDeletionFeedback(User user, FeedbackAddDto feedbackAddDto) { + if (feedbackAddDto == null || feedbackAddDto.getMessage() == null || feedbackAddDto.getMessage().isBlank()) { + return; + } + + UUID feedbackId = feedbackAddDto.getFeedbackId() != null ? feedbackAddDto.getFeedbackId() : UUID.randomUUID(); + + AccountDeletionFeedback feedback = AccountDeletionFeedback.builder() + .feedbackId(feedbackId) + .deletedUserId(user.getId()) + .socialType(user.getSocialType()) + .emailHash(hashEmail(user.getEmail())) + .message(feedbackAddDto.getMessage()) + .createdAt(LocalDateTime.now()) + .build(); + + accountDeletionFeedbackRepository.save(feedback); + } + + private String hashEmail(String email) { + if (email == null) { + return null; + } + + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] encodedHash = digest.digest(email.trim().toLowerCase().getBytes(StandardCharsets.UTF_8)); + StringBuilder hexString = new StringBuilder(encodedHash.length * 2); + for (byte b : encodedHash) { + hexString.append(String.format("%02x", b)); + } + return hexString.toString(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 algorithm is not available.", e); + } + } + } diff --git a/ontime-back/src/main/resources/db/migration/V9__create_account_deletion_feedback.sql b/ontime-back/src/main/resources/db/migration/V9__create_account_deletion_feedback.sql new file mode 100644 index 00000000..e1433dc6 --- /dev/null +++ b/ontime-back/src/main/resources/db/migration/V9__create_account_deletion_feedback.sql @@ -0,0 +1,8 @@ +CREATE TABLE account_deletion_feedback ( + feedback_id BINARY(16) PRIMARY KEY, + deleted_user_id BIGINT, + social_type VARCHAR(255), + email_hash VARCHAR(64), + message TEXT, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/ontime-back/src/test/java/devkor/ontime_back/controller/UserAuthControllerTest.java b/ontime-back/src/test/java/devkor/ontime_back/controller/UserAuthControllerTest.java index 83a30298..672db798 100644 --- a/ontime-back/src/test/java/devkor/ontime_back/controller/UserAuthControllerTest.java +++ b/ontime-back/src/test/java/devkor/ontime_back/controller/UserAuthControllerTest.java @@ -4,6 +4,7 @@ import devkor.ontime_back.ControllerTestSupport; import devkor.ontime_back.TestSecurityConfig; import devkor.ontime_back.dto.ChangePasswordDto; +import devkor.ontime_back.dto.FeedbackAddDto; import devkor.ontime_back.dto.UserAdditionalInfoDto; import devkor.ontime_back.dto.UserInfoResponse; import devkor.ontime_back.dto.UserSignUpDto; @@ -100,7 +101,7 @@ void changePassword() throws Exception { void deleteUser() throws Exception { // given when(userAuthService.getUserIdFromToken(any())).thenReturn(1L); - when(userAuthService.deleteUser(any(Long.class))).thenReturn(1L); + when(userAuthService.deleteUser(any(Long.class), any())).thenReturn(1L); // when // then mockMvc.perform( @@ -114,4 +115,29 @@ void deleteUser() throws Exception { .andExpect(jsonPath("$.status").value("success")) .andExpect(jsonPath("$.message").value("계정이 성공적으로 삭제되었습니다!")); } -} \ No newline at end of file + + @DisplayName("탈퇴 피드백을 포함해 계정 삭제에 성공한다.") + @Test + void deleteUserWithFeedback() throws Exception { + // given + FeedbackAddDto request = FeedbackAddDto.builder() + .feedbackId(UUID.randomUUID()) + .message("탈퇴 피드백입니다.") + .build(); + + when(userAuthService.getUserIdFromToken(any())).thenReturn(1L); + when(userAuthService.deleteUser(any(Long.class), any(FeedbackAddDto.class))).thenReturn(1L); + + // when // then + mockMvc.perform( + delete("/users/me/delete") + .content(objectMapper.writeValueAsString(request)) + .contentType(MediaType.APPLICATION_JSON) + ) + .andDo(print()) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("200")) + .andExpect(jsonPath("$.status").value("success")) + .andExpect(jsonPath("$.message").value("계정이 성공적으로 삭제되었습니다!")); + } +} diff --git a/ontime-back/src/test/java/devkor/ontime_back/service/UserAuthServiceTest.java b/ontime-back/src/test/java/devkor/ontime_back/service/UserAuthServiceTest.java index 2504eac3..b2b0da82 100644 --- a/ontime-back/src/test/java/devkor/ontime_back/service/UserAuthServiceTest.java +++ b/ontime-back/src/test/java/devkor/ontime_back/service/UserAuthServiceTest.java @@ -2,12 +2,16 @@ import com.google.firebase.auth.UserInfo; import devkor.ontime_back.dto.ChangePasswordDto; +import devkor.ontime_back.dto.FeedbackAddDto; import devkor.ontime_back.dto.UserAdditionalInfoDto; import devkor.ontime_back.dto.UserInfoResponse; import devkor.ontime_back.dto.UserSignUpDto; +import devkor.ontime_back.entity.AccountDeletionFeedback; import devkor.ontime_back.entity.Role; +import devkor.ontime_back.entity.SocialType; import devkor.ontime_back.entity.User; import devkor.ontime_back.entity.UserSetting; +import devkor.ontime_back.repository.AccountDeletionFeedbackRepository; import devkor.ontime_back.repository.UserRepository; import devkor.ontime_back.repository.UserSettingRepository; import devkor.ontime_back.response.GeneralException; @@ -44,6 +48,8 @@ class UserAuthServiceTest { private UserRepository userRepository; @Autowired private UserSettingRepository userSettingRepository; + @Autowired + private AccountDeletionFeedbackRepository accountDeletionFeedbackRepository; @Autowired private PasswordEncoder passwordEncoder; @@ -53,6 +59,7 @@ class UserAuthServiceTest { @AfterEach void tearDown() { + accountDeletionFeedbackRepository.deleteAllInBatch(); userSettingRepository.deleteAllInBatch(); userRepository.deleteAllInBatch(); } @@ -279,6 +286,65 @@ void deleteUser(){ assertThat(userRepository.findById(targetUserId)).isEmpty(); } + @DisplayName("계정 삭제 시 선택 피드백을 익명화하여 저장한다") + @Test + void deleteUserWithFeedback(){ + // given + User addedUser = User.builder() + .email("USER@example.com") + .password(passwordEncoder.encode("password1234")) + .name("junbeom") + .socialType(SocialType.GOOGLE) + .build(); + userRepository.save(addedUser); + + UUID feedbackId = UUID.randomUUID(); + FeedbackAddDto feedbackAddDto = FeedbackAddDto.builder() + .feedbackId(feedbackId) + .message("탈퇴 이유입니다.") + .build(); + + Long targetUserId = addedUser.getId(); + + // when + Long deletedUserId = userAuthService.deleteUser(targetUserId, feedbackAddDto); + + // then + assertThat(deletedUserId).isEqualTo(targetUserId); + assertThat(userRepository.findById(targetUserId)).isEmpty(); + + AccountDeletionFeedback feedback = accountDeletionFeedbackRepository.findById(feedbackId) + .orElseThrow(); + assertThat(feedback.getDeletedUserId()).isEqualTo(targetUserId); + assertThat(feedback.getSocialType()).isEqualTo(SocialType.GOOGLE); + assertThat(feedback.getMessage()).isEqualTo("탈퇴 이유입니다."); + assertThat(feedback.getEmailHash()).hasSize(64); + assertThat(feedback.getEmailHash()).doesNotContain("USER@example.com"); + assertThat(feedback.getCreatedAt()).isNotNull(); + } + + @DisplayName("계정 삭제 시 피드백이 없어도 삭제된다") + @Test + void deleteUserWithoutFeedback(){ + // given + User addedUser = User.builder() + .email("user@example.com") + .password(passwordEncoder.encode("password1234")) + .name("junbeom") + .build(); + userRepository.save(addedUser); + + Long targetUserId = addedUser.getId(); + + // when + Long deletedUserId = userAuthService.deleteUser(targetUserId, null); + + // then + assertThat(deletedUserId).isEqualTo(targetUserId); + assertThat(userRepository.findById(targetUserId)).isEmpty(); + assertThat(accountDeletionFeedbackRepository.findAll()).isEmpty(); + } + private UserSignUpDto getUserSignUpDto(String email, String password, String name) { @@ -289,4 +355,4 @@ private UserSignUpDto getUserSignUpDto(String email, String password, String nam .build(); return userSignUpDto; } -} \ No newline at end of file +}