-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathUserService.java
More file actions
344 lines (299 loc) · 16.2 KB
/
Copy pathUserService.java
File metadata and controls
344 lines (299 loc) · 16.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
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
package com.leets.tdd.user.service;
import com.leets.tdd.global.jwt.JwtProvider;
import com.leets.tdd.global.jwt.RefreshTokenHasher;
import com.leets.tdd.auth.service.EmailVerificationService;
import com.leets.tdd.party.domain.PartyParticipant;
import com.leets.tdd.party.domain.PartyParticipantStatus;
import com.leets.tdd.party.domain.PartyStatus;
import com.leets.tdd.party.repository.DeliveryPartyRepository;
import com.leets.tdd.party.repository.PartyParticipantRepository;
import com.leets.tdd.settlement.domain.SettlementStatus;
import com.leets.tdd.user.domain.Dormitory;
import com.leets.tdd.user.domain.User;
import com.leets.tdd.user.dto.MyPageResponse;
import com.leets.tdd.user.dto.ProfileRegistrationRequest;
import com.leets.tdd.user.dto.ProfileRegistrationResponse;
import com.leets.tdd.user.dto.ChangePasswordRequest;
import com.leets.tdd.user.dto.ProfileUpdateRequest;
import com.leets.tdd.user.dto.ProfileUpdateResponse;
import com.leets.tdd.user.dto.PushSettingRequest;
import com.leets.tdd.user.dto.PushSettingResponse;
import com.leets.tdd.user.dto.PushSubscriptionRequest;
import com.leets.tdd.user.dto.WithdrawalRequest;
import com.leets.tdd.user.exception.UserErrorCode;
import com.leets.tdd.user.exception.UserException;
import com.leets.tdd.user.repository.DormitoryRepository;
import com.leets.tdd.user.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.EnumSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 마이페이지 조회 + 계정등록(회원가입 완료) API.
* 계정등록은 signup_token 없이 email + email_verification_codes의 verified_at(15분 이내)로
* 신원을 확인한다. 기존에 탈퇴(DELETED)했던 계정이면 재사용(reactivate)하고,
* ACTIVE/SUSPENDED면 이미 가입된 이메일로, BANNED거나 정지기간이 안 지난 DELETED면 가입을 막는다.
*/
@Service
@RequiredArgsConstructor
public class UserService {
private static final int MAX_NICKNAME_GENERATION_ATTEMPTS = 5;
private final UserRepository userRepository;
private final DormitoryRepository dormitoryRepository;
private final JwtProvider jwtProvider;
private final RefreshTokenHasher refreshTokenHasher;
private final EmailVerificationService emailVerificationService;
private final PasswordEncoder passwordEncoder;
private final NicknameGenerator nicknameGenerator;
private final DeliveryPartyRepository deliveryPartyRepository;
private final PartyParticipantRepository partyParticipantRepository;
// 탈퇴 제한: 진행 중인 배달 팟(모집중/마감/주문완료) 또는 완료됐지만 정산이 안 끝난 팟이 있으면 막는다.
private static final Set<PartyStatus> ONGOING_PARTY_STATUSES =
EnumSet.of(PartyStatus.RECRUITING, PartyStatus.CLOSED, PartyStatus.ORDERED);
private static final Set<SettlementStatus> SETTLEMENT_TERMINAL_STATUSES =
EnumSet.of(SettlementStatus.COMPLETED, SettlementStatus.CANCELED);
@Transactional
public MyPageResponse getMyPage(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND));
if (user.isSuspensionExpired()) {
user.liftSuspension();
}
Dormitory dormitory = dormitoryRepository.findByUserId(userId).orElse(null);
return toMyPageResponse(user, dormitory);
}
@Transactional
public ProfileRegistrationResponse completeSignup(ProfileRegistrationRequest request) {
String email = request.email();
// 확인과 소비(삭제)를 하나의 원자적 연산으로 묶어서, 동시에 같은 이메일로 여러 요청이
// 와도 단 하나만 통과하게 한다(TOCTOU 방지). 이후 로직이 실패하면 @Transactional에 의해
// 이 소비도 함께 롤백되므로, 같은 인증 기록으로 재시도할 수 있다.
if (!emailVerificationService.consumeSignupVerification(email)) {
throw new UserException(UserErrorCode.INVALID_VERIFICATION);
}
String nickname = resolveNickname(request.nickname());
String encodedPassword = passwordEncoder.encode(request.password());
User user = userRepository.findByEmail(email).orElse(null);
IssuedTokens tokens;
if (user == null) {
user = new User(email, nickname, encodedPassword, "", LocalDateTime.now());
userRepository.save(user);
tokens = issueTokens(user.getId());
user.updateRefreshToken(refreshTokenHasher.hash(tokens.refreshToken()), tokens.refreshTokenExpiresAt());
} else {
validateExistingUserForRegistration(user);
tokens = issueTokens(user.getId());
user.reactivate(nickname, encodedPassword,
refreshTokenHasher.hash(tokens.refreshToken()), tokens.refreshTokenExpiresAt());
}
userRepository.save(user);
applyDormitory(user.getId(), request.dormitory());
return new ProfileRegistrationResponse(
user.getNickname(), request.dormitory(), tokens.accessToken(), tokens.refreshToken(), "Bearer");
}
/**
* 마이페이지 > 프로필 수정. 닉네임/기숙사 동/프로필 사진을 수정한다.
* 닉네임이 기존과 같으면(대소문자까지 완전히 동일) 중복 검사에서 제외한다(자기 자신과 비교해
* 항상 중복으로 걸리는 것을 방지). 기숙사 정보가 아직 없는 사용자가 처음 동을 등록하는 경우도
* 이 API로 처리하며, 이때는 인증 사진 없이 NOT_SUBMITTED 상태로 row를 새로 만든다.
*/
@Transactional
public ProfileUpdateResponse updateProfile(Long userId, ProfileUpdateRequest request) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND));
String newNickname = request.nickname();
if (!newNickname.equals(user.getNickname()) && userRepository.existsByNickname(newNickname)) {
throw new UserException(UserErrorCode.NICKNAME_DUPLICATE);
}
String newProfileImageUrl = request.profileImageUrl();
user.updateProfile(newNickname, newProfileImageUrl == null || newProfileImageUrl.isBlank()
? null : newProfileImageUrl);
userRepository.save(user);
Dormitory dormitory = dormitoryRepository.findByUserId(userId).orElse(null);
if (dormitory == null) {
dormitory = new Dormitory(userId, request.dormitory(), null);
dormitoryRepository.save(dormitory);
} else {
dormitory.changeDormitory(request.dormitory());
}
return new ProfileUpdateResponse(user.getNickname(), dormitory.getDormitory(), user.getProfileImageUrl());
}
/**
* 마이페이지 > 알림 설정. 전체 알림 on/off 통합 토글 하나만 갱신한다(MVP 범위).
* 카테고리 구분, 다른 도메인 조회/연동 없이 User.pushEnabled만 바꾼다.
*/
@Transactional
public PushSettingResponse updatePushSetting(Long userId, PushSettingRequest request) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND));
user.updatePushEnabled(request.pushEnabled());
userRepository.save(user);
return new PushSettingResponse(user.isPushEnabled());
}
/**
* 마이페이지 > 알림 구독 등록. 브라우저 PushManager.subscribe()로 발급받은 endpoint/키를
* 저장한다(Web Push 방식, FCM 아님 - User 엔티티 컬럼이 이미 endpoint/p256dh/auth 구조).
* 기존 구독이 있어도 그냥 덮어쓴다(기기 교체/브라우저 재설치 시 재구독하는 흔한 케이스라
* 별도 중복 에러 없이 최신 값으로 갱신). pushEnabled는 건드리지 않는다(updatePushSetting의
* 별도 관심사).
*/
@Transactional
public void registerPushSubscription(Long userId, PushSubscriptionRequest request) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND));
user.updatePushSubscription(request.endpoint(), request.p256dhKey(), request.authKey());
userRepository.save(user);
}
/**
* 마이페이지 > 비밀번호 수정. 현재 비밀번호로 본인 확인 후 새 비밀번호로 바꾸고,
* 기존 refresh token을 무효화한다(clearRefreshToken - 로그아웃/탈퇴와 동일한 관례).
* access token 자체는 상태 없는 JWT라 만료 전까지는 계속 쓸 수 있고, 재로그인은
* refresh token으로 재발급받아야 할 때만 필요해진다.
*/
@Transactional
public void changePassword(Long userId, ChangePasswordRequest request) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND));
if (!passwordEncoder.matches(request.currentPassword(), user.getPassword())) {
throw new UserException(UserErrorCode.CURRENT_PASSWORD_MISMATCH);
}
if (passwordEncoder.matches(request.newPassword(), user.getPassword())) {
throw new UserException(UserErrorCode.NEW_PASSWORD_SAME_AS_CURRENT);
}
user.updatePassword(passwordEncoder.encode(request.newPassword()));
user.clearRefreshToken();
userRepository.save(user);
}
/**
* 계정탈퇴. access token으로 신원은 이미 확인됐으니, 비밀번호 재검증 후 soft delete(status
* = DELETED) 처리하고 refresh token도 무효화한다(로그아웃과 동일하게 clearRefreshToken()).
* suspendedUntil/noShowApprovedCount/mannerTemperature는 softDelete()가 건드리지 않으므로
* 그대로 유지된다(정지 우회 방지 + 재가입 시 이력 복원).
* <p>
* 방장/참여자 구분 없이 본인이 속한 팟 중 진행 중(RECRUITING/CLOSED/ORDERED)인 팟이 있으면
* ACTIVE_POT_EXISTS로, COMPLETED인데 정산이 아직 안 끝난(SettlementStatus가 COMPLETED/CANCELED가
* 아닌) 팟이 있으면 UNSETTLED_POT_EXISTS로 탈퇴를 막는다. 방장 탈퇴로 정산 트리거가 사라지는 문제,
* 참여자가 노쇼 신고 전에 탈퇴로 회피하는 문제를 둘 다 막기 위함이다.
*/
@Transactional
public void withdraw(Long userId, WithdrawalRequest request) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND));
if (!user.canWithdraw()) {
throw new UserException(UserErrorCode.WITHDRAWAL_NOT_ALLOWED);
}
if (!passwordEncoder.matches(request.password(), user.getPassword())) {
throw new UserException(UserErrorCode.PASSWORD_MISMATCH);
}
List<Long> joinedPartyIds = joinedPartyIds(userId);
if (hasOngoingDeliveryParty(userId, joinedPartyIds)) {
throw new UserException(UserErrorCode.ACTIVE_POT_EXISTS);
}
if (hasUnsettledDeliveryParty(userId, joinedPartyIds)) {
throw new UserException(UserErrorCode.UNSETTLED_POT_EXISTS);
}
user.softDelete();
user.clearRefreshToken();
userRepository.save(user);
}
// 참여를 취소하지 않은(JOINED) 팟 id 목록 - 참여자(participant) 기준 체크에 재사용
private List<Long> joinedPartyIds(Long userId) {
return partyParticipantRepository
.findAllByUserIdAndStatus(userId, PartyParticipantStatus.JOINED)
.stream()
.map(PartyParticipant::getPartyId)
.collect(Collectors.toList());
}
// RECRUITING/CLOSED/ORDERED로 진행 중인 팟이 있는지 (방장 + 참여자 기준)
private boolean hasOngoingDeliveryParty(Long userId, List<Long> joinedPartyIds) {
if (deliveryPartyRepository.existsByCreatorIdAndStatusIn(userId, ONGOING_PARTY_STATUSES)) {
return true;
}
if (joinedPartyIds.isEmpty()) {
return false;
}
return deliveryPartyRepository.existsByIdInAndStatusIn(joinedPartyIds, ONGOING_PARTY_STATUSES);
}
// COMPLETED인데 정산이 아직 안 끝난 팟이 있는지 (방장 + 참여자 기준)
private boolean hasUnsettledDeliveryParty(Long userId, List<Long> joinedPartyIds) {
if (deliveryPartyRepository.existsByCreatorIdAndStatusAndSettlementStatusNotIn(
userId, PartyStatus.COMPLETED, SETTLEMENT_TERMINAL_STATUSES)) {
return true;
}
if (joinedPartyIds.isEmpty()) {
return false;
}
return deliveryPartyRepository.existsByIdInAndStatusAndSettlementStatusNotIn(
joinedPartyIds, PartyStatus.COMPLETED, SETTLEMENT_TERMINAL_STATUSES);
}
private record IssuedTokens(String accessToken, String refreshToken, LocalDateTime refreshTokenExpiresAt) {
}
private IssuedTokens issueTokens(Long userId) {
String accessToken = jwtProvider.createAccessToken(userId);
String refreshToken = jwtProvider.createRefreshToken(userId);
LocalDateTime refreshTokenExpiresAt = LocalDateTime.now().plus(jwtProvider.getRefreshTokenValidity());
return new IssuedTokens(accessToken, refreshToken, refreshTokenExpiresAt);
}
/**
* 기존 회원(email로 찾은) 상태별 가입 가능 여부.
* ACTIVE/SUSPENDED: 이미 쓰고 있는 계정 -> 이미 가입된 이메일
* BANNED: 영구 제한 -> 가입 불가
* DELETED: 탈퇴했던 계정. 정지기간이 아직 안 지났으면(탈퇴로 정지 우회 방지) 가입 불가,
* 지났으면 재사용(reactivate) 대상으로 통과시킨다.
*/
private void validateExistingUserForRegistration(User user) {
switch (user.getStatus()) {
case ACTIVE, SUSPENDED -> throw new UserException(UserErrorCode.ALREADY_REGISTERED_EMAIL);
case BANNED -> throw new UserException(UserErrorCode.REGISTRATION_BLOCKED);
case DELETED -> {
if (user.isWithinSuspensionPeriod()) {
throw new UserException(UserErrorCode.REGISTRATION_BLOCKED);
}
}
}
}
private String resolveNickname(String requestedNickname) {
if (requestedNickname != null && !requestedNickname.isBlank()) {
if (userRepository.existsByNickname(requestedNickname)) {
throw new UserException(UserErrorCode.NICKNAME_DUPLICATE);
}
return requestedNickname;
}
for (int attempt = 0; attempt < MAX_NICKNAME_GENERATION_ATTEMPTS; attempt++) {
String candidate = nicknameGenerator.generate();
if (!userRepository.existsByNickname(candidate)) {
return candidate;
}
}
throw new UserException(UserErrorCode.NICKNAME_GENERATION_FAILED);
}
private void applyDormitory(Long userId, String dormitory) {
if (dormitory == null || dormitory.isBlank()) {
return;
}
dormitoryRepository.findByUserId(userId).ifPresentOrElse(
existing -> existing.resubmit(dormitory, null),
() -> dormitoryRepository.save(new Dormitory(userId, dormitory, null))
);
}
private MyPageResponse toMyPageResponse(User user, Dormitory dormitory) {
return new MyPageResponse(
user.getNickname(),
user.getProfileImageUrl(),
user.getMannerTemperature(),
user.getNoShowApprovedCount(),
user.getSuspendedUntil(),
user.getStatus().name(),
dormitory != null ? dormitory.getDormitory() : null,
dormitory != null ? dormitory.getDormStatus().name() : null,
dormitory != null ? dormitory.getDormVerifiedAt() : null,
dormitory != null ? dormitory.getDormVerifiedUntil() : null,
dormitory != null ? dormitory.getRejectReason() : null
);
}
}