-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathUserController.java
More file actions
269 lines (235 loc) · 13.2 KB
/
Copy pathUserController.java
File metadata and controls
269 lines (235 loc) · 13.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
package com.carecode.domain.user.controller;
import com.carecode.core.annotation.LogExecutionTime;
import com.carecode.core.controller.BaseController;
import com.carecode.core.handler.ApiSuccess;
import com.carecode.core.security.CurrentUserFacade;
import com.carecode.domain.user.app.UserFacade;
import com.carecode.domain.user.dto.request.UserUpdateRequestDto;
import com.carecode.domain.user.dto.response.ProfileImageResponse;
import com.carecode.domain.user.dto.response.UserDto;
import com.carecode.domain.user.dto.response.UserProfileCompletionResponse;
import com.carecode.domain.user.dto.response.UserProfileMissingFields;
import com.carecode.domain.user.entity.User;
import com.carecode.domain.user.mapper.UserMapper;
import com.carecode.domain.user.service.UserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDateTime;
import java.util.Map;
/**
* 본인 계정 전용 API.
*
* <p>이 컨트롤러의 모든 엔드포인트는 "로그인한 사람이 자기 자신에게" 하는 동작만 다룬다.
* 예전에는 여기에 역할 변경·사용자 검색·전체 목록 조회 같은 관리 기능이 함께 있었고,
* 클래스 레벨 제약이 isAuthenticated() 뿐이라 아무 회원이나
* PUT /users/{id}/role 로 자신을 ADMIN 으로 올릴 수 있었다.
* 관리 기능은 전부 /api/admin/users 로 옮겼다.
*
* <p>경로 변수 userId 가 남아 있는 엔드포인트는 기존 클라이언트 호환을 위해 형태만 유지하며,
* 실제로는 {@link CurrentUserFacade#requireSelf} 로 본인인지 확인한 뒤 본인 엔티티로만 동작한다.
* 신규 클라이언트는 /users/me/... 별칭을 사용한다.
*/
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
@Slf4j
@PreAuthorize("isAuthenticated()")
@Tag(name = "사용자", description = "본인 계정 관리 API (프로필, 위치, 탈퇴)")
public class UserController extends BaseController {
private final UserService userService;
private final UserFacade userFacade;
private final UserMapper userMapper;
private final CurrentUserFacade currentUserFacade;
// ==================== 프로필 조회 ====================
@GetMapping({"/profile", "/me"})
@LogExecutionTime
@Operation(summary = "내 프로필 조회", description = "현재 로그인한 사용자의 프로필 정보 조회")
@SecurityRequirement(name = "Bearer Authentication")
public ResponseEntity<UserDto> getCurrentUserProfile() {
return ResponseEntity.ok(userFacade.getUserByEmail(getCurrentUserEmail()));
}
@GetMapping({"/profile/completion", "/me/profile-completion"})
@LogExecutionTime
@Operation(summary = "프로필 완성도 체크", description = "내 프로필의 완성도를 확인")
@SecurityRequirement(name = "Bearer Authentication")
public ResponseEntity<UserProfileCompletionResponse> checkProfileCompletion() {
User user = userFacade.getUserEntityByEmail(getCurrentUserEmail());
return ResponseEntity.ok(calculateProfileCompletion(user));
}
// ==================== 프로필 수정 ====================
@PutMapping({"/profile", "/me"})
@LogExecutionTime
@Operation(summary = "내 프로필 수정", description = "이름·연락처·생년월일·성별·주소 등을 수정")
@SecurityRequirement(name = "Bearer Authentication")
public ResponseEntity<UserDto> updateProfile(
@Parameter(description = "업데이트할 프로필 정보", required = true)
@Valid @RequestBody UserUpdateRequestDto updateDto) {
User user = userService.getUserEntityByEmail(getCurrentUserEmail());
userMapper.updateUserFromRequest(updateDto, user);
user.setUpdatedAt(LocalDateTime.now());
return ResponseEntity.ok(userMapper.toDto(userService.saveUser(user)));
}
@PatchMapping({"/profile/nickname", "/me/nickname"})
@LogExecutionTime
@Operation(summary = "닉네임 변경", description = "표시 닉네임을 변경")
@SecurityRequirement(name = "Bearer Authentication")
public ResponseEntity<ApiSuccess> updateNickname(
@Parameter(description = "새로운 닉네임", required = true) @RequestBody Map<String, String> request) {
String newNickname = request.get("nickname");
if (newNickname == null || newNickname.trim().isEmpty()) {
throw new IllegalArgumentException("닉네임은 필수입니다");
}
String trimmed = newNickname.trim();
if (trimmed.length() < 2 || trimmed.length() > 10) {
throw new IllegalArgumentException("닉네임은 2-10자 사이여야 합니다");
}
User user = userService.getUserEntityByEmail(getCurrentUserEmail());
user.setName(trimmed);
user.setUpdatedAt(LocalDateTime.now());
userService.saveUser(user);
return ResponseEntity.ok(ApiSuccess.of("닉네임이 업데이트되었습니다"));
}
@PutMapping("/me/profile-image")
@LogExecutionTime
@Operation(summary = "프로필 이미지 변경")
@SecurityRequirement(name = "Bearer Authentication")
public ResponseEntity<Void> updateMyProfileImage(
@Parameter(description = "프로필 이미지 URL", required = true) @RequestParam String profileImageUrl) {
userFacade.updateProfileImage(selfDbId(), profileImageUrl);
return ResponseEntity.ok().build();
}
/**
* 프로필 이미지 업로드.
*
* 기존 경로는 이미지 **URL 문자열만** 받았다. 클라이언트가 파일을 올릴 곳이 없어서
* 프로필 사진을 바꾸는 화면을 만들 수 없었다. 건강기록 첨부와 같은 저장소를 쓴다.
*/
@PostMapping(value = "/me/profile-image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@LogExecutionTime
@Operation(summary = "프로필 이미지 업로드", description = "이미지 파일을 올리고 저장된 URL 을 돌려준다")
@SecurityRequirement(name = "Bearer Authentication")
public ResponseEntity<ProfileImageResponse> uploadMyProfileImage(
@Parameter(description = "이미지 파일", required = true) @RequestPart("file") MultipartFile file) {
return ResponseEntity.ok(userFacade.uploadProfileImage(selfDbId(), file));
}
@PutMapping("/{userId}/profile-image")
@LogExecutionTime
@Operation(summary = "프로필 이미지 변경 (구 경로)",
description = "본인만 변경할 수 있습니다. 신규 클라이언트는 /users/me/profile-image 를 사용하세요")
@SecurityRequirement(name = "Bearer Authentication")
public ResponseEntity<Void> updateProfileImage(
@Parameter(description = "사용자 ID", required = true) @PathVariable String userId,
@Parameter(description = "프로필 이미지 URL", required = true) @RequestParam String profileImageUrl) {
User self = currentUserFacade.requireSelf(userId);
userFacade.updateProfileImage(String.valueOf(self.getId()), profileImageUrl);
return ResponseEntity.ok().build();
}
// ==================== 위치 ====================
@PutMapping("/me/location")
@LogExecutionTime
@Operation(summary = "내 위치 갱신", description = "주변 시설 추천에 사용할 현재 위치를 갱신")
@SecurityRequirement(name = "Bearer Authentication")
public ResponseEntity<UserDto> updateMyLocation(
@Parameter(description = "위도", required = true) @RequestParam Double latitude,
@Parameter(description = "경도", required = true) @RequestParam Double longitude) {
return ResponseEntity.ok(userFacade.updateUserLocation(selfDbId(), latitude, longitude));
}
@PutMapping("/{userId}/location")
@LogExecutionTime
@Operation(summary = "위치 갱신 (구 경로)",
description = "본인만 변경할 수 있습니다. 신규 클라이언트는 /users/me/location 을 사용하세요")
@SecurityRequirement(name = "Bearer Authentication")
public ResponseEntity<UserDto> updateUserLocation(
@Parameter(description = "사용자 ID", required = true) @PathVariable String userId,
@Parameter(description = "위도", required = true) @RequestParam Double latitude,
@Parameter(description = "경도", required = true) @RequestParam Double longitude) {
User self = currentUserFacade.requireSelf(userId);
return ResponseEntity.ok(userFacade.updateUserLocation(String.valueOf(self.getId()), latitude, longitude));
}
// ==================== 회원 탈퇴 ====================
@PutMapping("/me/deactivate")
@LogExecutionTime
@Operation(summary = "내 계정 비활성화", description = "데이터는 보존되며 관리자를 통해 복구할 수 있습니다")
@SecurityRequirement(name = "Bearer Authentication")
public ResponseEntity<ApiSuccess> deactivateMe() {
userFacade.deactivateUser(selfDbId());
return ResponseEntity.ok(ApiSuccess.of("회원 탈퇴가 완료되었습니다."));
}
@PutMapping("/{userId}/deactivate")
@LogExecutionTime
@Operation(summary = "계정 비활성화 (구 경로)", description = "본인만 요청할 수 있습니다")
@SecurityRequirement(name = "Bearer Authentication")
public ResponseEntity<ApiSuccess> deactivateUser(
@Parameter(description = "사용자 ID", required = true) @PathVariable String userId) {
User self = currentUserFacade.requireSelf(userId);
userFacade.deactivateUser(String.valueOf(self.getId()));
return ResponseEntity.ok(ApiSuccess.of("회원 탈퇴가 완료되었습니다."));
}
@DeleteMapping("/me")
@LogExecutionTime
@Operation(summary = "회원 탈퇴 (소프트 삭제)", description = "데이터는 보존되며 필요시 복구 가능")
@SecurityRequirement(name = "Bearer Authentication")
public ResponseEntity<ApiSuccess> deleteMe() {
userFacade.deleteUser(selfDbId());
return ResponseEntity.ok(ApiSuccess.of("회원 탈퇴가 완료되었습니다. 데이터는 보존되며 필요시 복구 가능합니다."));
}
@DeleteMapping("/{userId}")
@LogExecutionTime
@Operation(summary = "회원 탈퇴 (구 경로)", description = "본인만 요청할 수 있습니다")
@SecurityRequirement(name = "Bearer Authentication")
public ResponseEntity<ApiSuccess> deleteUser(
@Parameter(description = "사용자 ID", required = true) @PathVariable String userId) {
User self = currentUserFacade.requireSelf(userId);
userFacade.deleteUser(String.valueOf(self.getId()));
return ResponseEntity.ok(ApiSuccess.of("회원 탈퇴가 완료되었습니다. 데이터는 보존되며 필요시 복구 가능합니다."));
}
// ==================== 유틸리티 ====================
private String getCurrentUserEmail() {
return currentUserFacade.requireCurrentUserEmail();
}
/** 서비스 계층이 DB PK 를 기대하므로, 본인 확인 뒤에는 항상 PK 로 정규화해 넘긴다. */
private String selfDbId() {
return String.valueOf(currentUserFacade.requireCurrentUserDbId());
}
private UserProfileCompletionResponse calculateProfileCompletion(User user) {
UserProfileMissingFields missingFields =
UserProfileMissingFields.builder()
.needsRealName(isBlank(user.getName()) || user.getName().contains("_"))
.needsPhoneNumber(isBlank(user.getPhoneNumber()))
.needsBirthDate(user.getBirthDate() == null)
.needsGender(user.getGender() == null)
.needsAddress(isBlank(user.getAddress()))
.build();
int totalFields = 5;
int completedFields = 0;
if (!missingFields.isNeedsRealName()) completedFields++;
if (!missingFields.isNeedsPhoneNumber()) completedFields++;
if (!missingFields.isNeedsBirthDate()) completedFields++;
if (!missingFields.isNeedsGender()) completedFields++;
if (!missingFields.isNeedsAddress()) completedFields++;
int percentage = (completedFields * 100) / totalFields;
boolean isComplete = completedFields == totalFields;
String message = isComplete
? "프로필이 완성되었습니다!"
: String.format("프로필 완성도: %d%% (%d개 항목 추가 필요)", percentage, totalFields - completedFields);
return UserProfileCompletionResponse.builder()
.isComplete(isComplete)
.completionPercentage(percentage)
.message(message)
.missingFields(missingFields)
.build();
}
private boolean isBlank(String str) {
return str == null || str.trim().isEmpty();
}
}