Skip to content

Commit 2c8675a

Browse files
committed
Add account deletion feedback
1 parent f3d7f3a commit 2c8675a

9 files changed

Lines changed: 212 additions & 14 deletions

File tree

ontime-back/src/main/java/devkor/ontime_back/controller/SocialAuthController.java

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package devkor.ontime_back.controller;
22

3+
import devkor.ontime_back.dto.FeedbackAddDto;
34
import devkor.ontime_back.dto.OAuthAppleRequestDto;
45
import devkor.ontime_back.dto.OAuthGoogleUserDto;
56
import devkor.ontime_back.dto.OAuthKakaoUserDto;
@@ -112,24 +113,24 @@ public String appleRegisterOrLogin(@RequestBody OAuthAppleRequestDto appleLoginR
112113
summary = "애플 소셜 로그인 회원탈퇴"
113114
)
114115
@DeleteMapping("/apple/me")
115-
public String appleDeleteUser(HttpServletRequest request, HttpServletResponse response) throws Exception {
116+
public String appleDeleteUser(HttpServletRequest request, HttpServletResponse response, @RequestBody(required = false) FeedbackAddDto feedbackAddDto) throws Exception {
116117
Long userId = userAuthService.getUserIdFromToken(request);
117118
log.info("userId: {}", userId);
118119
appleLoginService.revokeToken(userId);
119-
userAuthService.deleteUser(userId);
120+
userAuthService.deleteUser(userId, feedbackAddDto);
120121
return "애플 로그인 회원탈퇴 성공";
121122
}
122123

123124
@Operation(
124125
summary = "구글 소셜 로그인 회원탈퇴"
125126
)
126127
@DeleteMapping("/google/me")
127-
public String googleDeleteUser(HttpServletRequest request, HttpServletResponse response) throws Exception {
128+
public String googleDeleteUser(HttpServletRequest request, HttpServletResponse response, @RequestBody(required = false) FeedbackAddDto feedbackAddDto) throws Exception {
128129
Long userId = userAuthService.getUserIdFromToken(request);
129130
log.info("userId: {}", userId);
130131
googleLoginService.revokeToken(userId);
131-
userAuthService.deleteUser(userId);
132-
return "애플 로그인 회원탈퇴 성공";
132+
userAuthService.deleteUser(userId, feedbackAddDto);
133+
return "구글 로그인 회원탈퇴 성공";
133134
}
134135

135136

ontime-back/src/main/java/devkor/ontime_back/controller/UserAuthController.java

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package devkor.ontime_back.controller;
22

33
import devkor.ontime_back.dto.ChangePasswordDto;
4+
import devkor.ontime_back.dto.FeedbackAddDto;
45
import devkor.ontime_back.dto.UserInfoResponse;
56
import devkor.ontime_back.dto.UserSignUpDto;
67
import devkor.ontime_back.entity.User;
@@ -107,11 +108,11 @@ public ResponseEntity<ApiResponseForm<String>> changePassword(HttpServletRequest
107108
@Operation(
108109
summary = "계정 삭제 (User 데이터 하드 삭제)",
109110
requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody(
110-
description = "계정 삭제 요청 JSON 데이터는 없음. 헤더에 토큰만 있으면 됨",
111+
description = "계정 삭제 요청 JSON 데이터는 선택사항. 탈퇴 피드백을 남기려면 feedbackId, message를 전달",
111112
content = @Content(
112113
schema = @Schema(
113114
type = "object",
114-
example = "{}"
115+
example = "{\"feedbackId\": \"d784cde3-9ff9-4054-872a-500bbcc2198a\", \"message\": \"탈퇴 피드백입니다.\"}"
115116
)
116117
)
117118
)
@@ -126,9 +127,9 @@ public ResponseEntity<ApiResponseForm<String>> changePassword(HttpServletRequest
126127
@ApiResponse(responseCode = "4XX", description = "계정 삭제 실패", content = @Content(mediaType = "application/json", schema = @Schema(example = "실패 메세지(토큰 오류 제외 비즈니스 로직 오류는 없음)")))
127128
})
128129
@DeleteMapping("/users/me/delete")
129-
public ResponseEntity<ApiResponseForm<?>> deleteUser(HttpServletRequest request) {
130+
public ResponseEntity<ApiResponseForm<?>> deleteUser(HttpServletRequest request, @RequestBody(required = false) FeedbackAddDto feedbackAddDto) {
130131
Long userId = userAuthService.getUserIdFromToken(request);
131-
userAuthService.deleteUser(userId);
132+
userAuthService.deleteUser(userId, feedbackAddDto);
132133
String message = "계정이 성공적으로 삭제되었습니다!";
133134
return ResponseEntity.ok(ApiResponseForm.success(null, message));
134135
}

ontime-back/src/main/java/devkor/ontime_back/dto/FeedbackAddDto.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
package devkor.ontime_back.dto;
22

3-
import lombok.Getter;
4-
import lombok.ToString;
3+
import lombok.*;
54

65
import java.util.UUID;
76

87
@ToString
98
@Getter
9+
@Builder
10+
@NoArgsConstructor
11+
@AllArgsConstructor
1012
public class FeedbackAddDto {
1113
private UUID feedbackId;
1214
private String message;
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package devkor.ontime_back.entity;
2+
3+
import jakarta.persistence.*;
4+
import lombok.*;
5+
6+
import java.time.LocalDateTime;
7+
import java.util.UUID;
8+
9+
@Getter
10+
@Entity
11+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
12+
@AllArgsConstructor
13+
@Builder
14+
public class AccountDeletionFeedback {
15+
16+
@Id
17+
private UUID feedbackId;
18+
19+
private Long deletedUserId;
20+
21+
@Enumerated(EnumType.STRING)
22+
private SocialType socialType;
23+
24+
@Column(length = 64)
25+
private String emailHash;
26+
27+
@Lob
28+
@Column(columnDefinition = "TEXT")
29+
private String message;
30+
31+
private LocalDateTime createdAt;
32+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package devkor.ontime_back.repository;
2+
3+
import devkor.ontime_back.entity.AccountDeletionFeedback;
4+
import org.springframework.data.jpa.repository.JpaRepository;
5+
import org.springframework.stereotype.Repository;
6+
7+
import java.util.UUID;
8+
9+
@Repository
10+
public interface AccountDeletionFeedbackRepository extends JpaRepository<AccountDeletionFeedback, UUID> {
11+
}

ontime-back/src/main/java/devkor/ontime_back/service/UserAuthService.java

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
package devkor.ontime_back.service;
22

33
import devkor.ontime_back.dto.ChangePasswordDto;
4+
import devkor.ontime_back.dto.FeedbackAddDto;
45
import devkor.ontime_back.dto.UserAdditionalInfoDto;
56
import devkor.ontime_back.dto.UserInfoResponse;
67
import devkor.ontime_back.dto.UserSignUpDto;
8+
import devkor.ontime_back.entity.AccountDeletionFeedback;
79
import devkor.ontime_back.entity.Role;
810
import devkor.ontime_back.entity.User;
911
import devkor.ontime_back.entity.UserAlarmSetting;
1012
import devkor.ontime_back.entity.UserSetting;
13+
import devkor.ontime_back.repository.AccountDeletionFeedbackRepository;
1114
import devkor.ontime_back.global.jwt.JwtTokenProvider;
1215
import devkor.ontime_back.repository.UserAlarmSettingRepository;
1316
import devkor.ontime_back.repository.UserRepository;
@@ -23,6 +26,10 @@
2326
import org.springframework.stereotype.Service;
2427
import org.springframework.transaction.annotation.Transactional;
2528

29+
import java.nio.charset.StandardCharsets;
30+
import java.security.MessageDigest;
31+
import java.security.NoSuchAlgorithmException;
32+
import java.time.LocalDateTime;
2633
import java.util.NoSuchElementException;
2734
import java.util.UUID;
2835

@@ -35,6 +42,7 @@ public class UserAuthService {
3542
private final UserRepository userRepository;
3643
private final UserSettingRepository userSettingRepository;
3744
private final UserAlarmSettingRepository userAlarmSettingRepository;
45+
private final AccountDeletionFeedbackRepository accountDeletionFeedbackRepository;
3846
private final PasswordEncoder passwordEncoder;
3947
private final JwtTokenProvider jwtTokenProvider;
4048

@@ -161,12 +169,55 @@ public User changePassword(Long userId, ChangePasswordDto changePasswordDto) {
161169

162170
@Transactional
163171
public Long deleteUser(Long userId) {
172+
return deleteUser(userId, null);
173+
}
174+
175+
@Transactional
176+
public Long deleteUser(Long userId, FeedbackAddDto feedbackAddDto) {
164177
User user = userRepository.findById(userId)
165178
.orElseThrow(() -> new IllegalArgumentException("유저를 찾을 수 없습니다."));
166179

180+
saveAccountDeletionFeedback(user, feedbackAddDto);
167181
userRepository.delete(user);
168182

169183
return userId;
170184
}
171185

186+
private void saveAccountDeletionFeedback(User user, FeedbackAddDto feedbackAddDto) {
187+
if (feedbackAddDto == null || feedbackAddDto.getMessage() == null || feedbackAddDto.getMessage().isBlank()) {
188+
return;
189+
}
190+
191+
UUID feedbackId = feedbackAddDto.getFeedbackId() != null ? feedbackAddDto.getFeedbackId() : UUID.randomUUID();
192+
193+
AccountDeletionFeedback feedback = AccountDeletionFeedback.builder()
194+
.feedbackId(feedbackId)
195+
.deletedUserId(user.getId())
196+
.socialType(user.getSocialType())
197+
.emailHash(hashEmail(user.getEmail()))
198+
.message(feedbackAddDto.getMessage())
199+
.createdAt(LocalDateTime.now())
200+
.build();
201+
202+
accountDeletionFeedbackRepository.save(feedback);
203+
}
204+
205+
private String hashEmail(String email) {
206+
if (email == null) {
207+
return null;
208+
}
209+
210+
try {
211+
MessageDigest digest = MessageDigest.getInstance("SHA-256");
212+
byte[] encodedHash = digest.digest(email.trim().toLowerCase().getBytes(StandardCharsets.UTF_8));
213+
StringBuilder hexString = new StringBuilder(encodedHash.length * 2);
214+
for (byte b : encodedHash) {
215+
hexString.append(String.format("%02x", b));
216+
}
217+
return hexString.toString();
218+
} catch (NoSuchAlgorithmException e) {
219+
throw new IllegalStateException("SHA-256 algorithm is not available.", e);
220+
}
221+
}
222+
172223
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
CREATE TABLE account_deletion_feedback (
2+
feedback_id BINARY(16) PRIMARY KEY,
3+
deleted_user_id BIGINT,
4+
social_type VARCHAR(255),
5+
email_hash VARCHAR(64),
6+
message TEXT,
7+
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
8+
);

ontime-back/src/test/java/devkor/ontime_back/controller/UserAuthControllerTest.java

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import devkor.ontime_back.ControllerTestSupport;
55
import devkor.ontime_back.TestSecurityConfig;
66
import devkor.ontime_back.dto.ChangePasswordDto;
7+
import devkor.ontime_back.dto.FeedbackAddDto;
78
import devkor.ontime_back.dto.UserAdditionalInfoDto;
89
import devkor.ontime_back.dto.UserInfoResponse;
910
import devkor.ontime_back.dto.UserSignUpDto;
@@ -100,7 +101,7 @@ void changePassword() throws Exception {
100101
void deleteUser() throws Exception {
101102
// given
102103
when(userAuthService.getUserIdFromToken(any())).thenReturn(1L);
103-
when(userAuthService.deleteUser(any(Long.class))).thenReturn(1L);
104+
when(userAuthService.deleteUser(any(Long.class), any())).thenReturn(1L);
104105

105106
// when // then
106107
mockMvc.perform(
@@ -114,4 +115,29 @@ void deleteUser() throws Exception {
114115
.andExpect(jsonPath("$.status").value("success"))
115116
.andExpect(jsonPath("$.message").value("계정이 성공적으로 삭제되었습니다!"));
116117
}
117-
}
118+
119+
@DisplayName("탈퇴 피드백을 포함해 계정 삭제에 성공한다.")
120+
@Test
121+
void deleteUserWithFeedback() throws Exception {
122+
// given
123+
FeedbackAddDto request = FeedbackAddDto.builder()
124+
.feedbackId(UUID.randomUUID())
125+
.message("탈퇴 피드백입니다.")
126+
.build();
127+
128+
when(userAuthService.getUserIdFromToken(any())).thenReturn(1L);
129+
when(userAuthService.deleteUser(any(Long.class), any(FeedbackAddDto.class))).thenReturn(1L);
130+
131+
// when // then
132+
mockMvc.perform(
133+
delete("/users/me/delete")
134+
.content(objectMapper.writeValueAsString(request))
135+
.contentType(MediaType.APPLICATION_JSON)
136+
)
137+
.andDo(print())
138+
.andExpect(status().isOk())
139+
.andExpect(jsonPath("$.code").value("200"))
140+
.andExpect(jsonPath("$.status").value("success"))
141+
.andExpect(jsonPath("$.message").value("계정이 성공적으로 삭제되었습니다!"));
142+
}
143+
}

ontime-back/src/test/java/devkor/ontime_back/service/UserAuthServiceTest.java

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,16 @@
22

33
import com.google.firebase.auth.UserInfo;
44
import devkor.ontime_back.dto.ChangePasswordDto;
5+
import devkor.ontime_back.dto.FeedbackAddDto;
56
import devkor.ontime_back.dto.UserAdditionalInfoDto;
67
import devkor.ontime_back.dto.UserInfoResponse;
78
import devkor.ontime_back.dto.UserSignUpDto;
9+
import devkor.ontime_back.entity.AccountDeletionFeedback;
810
import devkor.ontime_back.entity.Role;
11+
import devkor.ontime_back.entity.SocialType;
912
import devkor.ontime_back.entity.User;
1013
import devkor.ontime_back.entity.UserSetting;
14+
import devkor.ontime_back.repository.AccountDeletionFeedbackRepository;
1115
import devkor.ontime_back.repository.UserRepository;
1216
import devkor.ontime_back.repository.UserSettingRepository;
1317
import devkor.ontime_back.response.GeneralException;
@@ -44,6 +48,8 @@ class UserAuthServiceTest {
4448
private UserRepository userRepository;
4549
@Autowired
4650
private UserSettingRepository userSettingRepository;
51+
@Autowired
52+
private AccountDeletionFeedbackRepository accountDeletionFeedbackRepository;
4753

4854
@Autowired
4955
private PasswordEncoder passwordEncoder;
@@ -53,6 +59,7 @@ class UserAuthServiceTest {
5359

5460
@AfterEach
5561
void tearDown() {
62+
accountDeletionFeedbackRepository.deleteAllInBatch();
5663
userSettingRepository.deleteAllInBatch();
5764
userRepository.deleteAllInBatch();
5865
}
@@ -279,6 +286,65 @@ void deleteUser(){
279286
assertThat(userRepository.findById(targetUserId)).isEmpty();
280287
}
281288

289+
@DisplayName("계정 삭제 시 선택 피드백을 익명화하여 저장한다")
290+
@Test
291+
void deleteUserWithFeedback(){
292+
// given
293+
User addedUser = User.builder()
294+
.email("USER@example.com")
295+
.password(passwordEncoder.encode("password1234"))
296+
.name("junbeom")
297+
.socialType(SocialType.GOOGLE)
298+
.build();
299+
userRepository.save(addedUser);
300+
301+
UUID feedbackId = UUID.randomUUID();
302+
FeedbackAddDto feedbackAddDto = FeedbackAddDto.builder()
303+
.feedbackId(feedbackId)
304+
.message("탈퇴 이유입니다.")
305+
.build();
306+
307+
Long targetUserId = addedUser.getId();
308+
309+
// when
310+
Long deletedUserId = userAuthService.deleteUser(targetUserId, feedbackAddDto);
311+
312+
// then
313+
assertThat(deletedUserId).isEqualTo(targetUserId);
314+
assertThat(userRepository.findById(targetUserId)).isEmpty();
315+
316+
AccountDeletionFeedback feedback = accountDeletionFeedbackRepository.findById(feedbackId)
317+
.orElseThrow();
318+
assertThat(feedback.getDeletedUserId()).isEqualTo(targetUserId);
319+
assertThat(feedback.getSocialType()).isEqualTo(SocialType.GOOGLE);
320+
assertThat(feedback.getMessage()).isEqualTo("탈퇴 이유입니다.");
321+
assertThat(feedback.getEmailHash()).hasSize(64);
322+
assertThat(feedback.getEmailHash()).doesNotContain("USER@example.com");
323+
assertThat(feedback.getCreatedAt()).isNotNull();
324+
}
325+
326+
@DisplayName("계정 삭제 시 피드백이 없어도 삭제된다")
327+
@Test
328+
void deleteUserWithoutFeedback(){
329+
// given
330+
User addedUser = User.builder()
331+
.email("user@example.com")
332+
.password(passwordEncoder.encode("password1234"))
333+
.name("junbeom")
334+
.build();
335+
userRepository.save(addedUser);
336+
337+
Long targetUserId = addedUser.getId();
338+
339+
// when
340+
Long deletedUserId = userAuthService.deleteUser(targetUserId, null);
341+
342+
// then
343+
assertThat(deletedUserId).isEqualTo(targetUserId);
344+
assertThat(userRepository.findById(targetUserId)).isEmpty();
345+
assertThat(accountDeletionFeedbackRepository.findAll()).isEmpty();
346+
}
347+
282348

283349

284350
private UserSignUpDto getUserSignUpDto(String email, String password, String name) {
@@ -289,4 +355,4 @@ private UserSignUpDto getUserSignUpDto(String email, String password, String nam
289355
.build();
290356
return userSignUpDto;
291357
}
292-
}
358+
}

0 commit comments

Comments
 (0)