Skip to content

Commit e1f3e91

Browse files
authored
Merge pull request #90 from Leets-Official/feature/push-subscription
feat: 알림 구독 정보 API
2 parents 282ec03 + 4c9c014 commit e1f3e91

9 files changed

Lines changed: 367 additions & 4 deletions

File tree

src/main/java/com/leets/tdd/party/repository/DeliveryPartyRepository.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package com.leets.tdd.party.repository;
22

33
import com.leets.tdd.party.domain.DeliveryParty;
4+
import com.leets.tdd.party.domain.PartyStatus;
45
import com.leets.tdd.settlement.domain.SettlementStatus;
56
import jakarta.persistence.LockModeType;
7+
import java.util.Collection;
68
import java.util.List;
79
import java.util.Optional;
810
import org.springframework.data.jpa.repository.JpaRepository;
@@ -20,4 +22,22 @@ List<DeliveryParty> findAllByCreatorIdAndSettlementStatus(
2022
Long creatorId,
2123
SettlementStatus settlementStatus
2224
);
25+
26+
// 탈퇴 제한: 방장(creator)으로서 진행 중이거나(RECRUITING/CLOSED/ORDERED) 정산이 안 끝난 팟이 있는지 확인
27+
boolean existsByCreatorIdAndStatusIn(Long creatorId, Collection<PartyStatus> statuses);
28+
29+
boolean existsByCreatorIdAndStatusAndSettlementStatusNotIn(
30+
Long creatorId,
31+
PartyStatus status,
32+
Collection<SettlementStatus> settlementStatuses
33+
);
34+
35+
// 탈퇴 제한: 참여자(participant)로서 진행 중이거나 정산이 안 끝난 팟이 있는지 확인
36+
boolean existsByIdInAndStatusIn(Collection<Long> ids, Collection<PartyStatus> statuses);
37+
38+
boolean existsByIdInAndStatusAndSettlementStatusNotIn(
39+
Collection<Long> ids,
40+
PartyStatus status,
41+
Collection<SettlementStatus> settlementStatuses
42+
);
2343
}

src/main/java/com/leets/tdd/party/repository/PartyParticipantRepository.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,7 @@ public interface PartyParticipantRepository extends JpaRepository<PartyParticipa
2020
List<PartyParticipant> findAllByPartyIdIn(Collection<Long> partyIds);
2121

2222
List<PartyParticipant> findAllByUserIdAndPaymentStatusIsNotNull(Long userId);
23+
24+
// 탈퇴 제한: 이 사용자가 참여 중인(취소하지 않은) 팟 목록
25+
List<PartyParticipant> findAllByUserIdAndStatus(Long userId, PartyParticipantStatus status);
2326
}

src/main/java/com/leets/tdd/user/controller/UserController.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import com.leets.tdd.user.dto.ProfileUpdateResponse;
1919
import com.leets.tdd.user.dto.PushSettingRequest;
2020
import com.leets.tdd.user.dto.PushSettingResponse;
21+
import com.leets.tdd.user.dto.PushSubscriptionRequest;
2122
import com.leets.tdd.user.dto.WithdrawalRequest;
2223
import com.leets.tdd.user.service.UserService;
2324
import io.swagger.v3.oas.annotations.Operation;
@@ -176,6 +177,23 @@ public ResponseEntity<ApiResponse<PushSettingResponse>> updatePushSetting(
176177
return ResponseEntity.ok(ApiResponse.success("알림 설정이 변경되었습니다.", response));
177178
}
178179

180+
@Operation(
181+
summary = "알림 구독 등록",
182+
description = "브라우저 Web Push 구독 정보(endpoint/p256dhKey/authKey)를 저장한다. "
183+
+ "이미 등록된 구독이 있으면 최신 값으로 덮어쓴다(기기 교체/재설치 시 재구독 케이스). "
184+
+ "알림 on/off 자체는 이 API가 아니라 /me/push-setting으로 따로 관리한다. "
185+
+ "Authorization 헤더에 access token(Bearer)이 필요하다."
186+
)
187+
@SecurityRequirement(name = "bearerAuth")
188+
@PostMapping("/me/push-subscription")
189+
public ResponseEntity<ApiResponse<Void>> registerPushSubscription(
190+
@AuthenticationPrincipal UserPrincipal userPrincipal,
191+
@Valid @RequestBody PushSubscriptionRequest request
192+
) {
193+
userService.registerPushSubscription(userPrincipal.userId(), request);
194+
return ResponseEntity.ok(ApiResponse.success("구독 정보가 저장되었습니다."));
195+
}
196+
179197
@Operation(
180198
summary = "비밀번호 수정",
181199
description = "현재 비밀번호를 재확인한 뒤 새 비밀번호로 변경한다(기존과 동일한 비밀번호는 불가). "

src/main/java/com/leets/tdd/user/domain/User.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,16 @@ public void updatePushEnabled(boolean pushEnabled) {
224224
this.pushEnabled = pushEnabled;
225225
}
226226

227+
// Web Push 구독 등록/갱신에 사용(브라우저 PushManager.subscribe()가 돌려주는
228+
// {endpoint, keys: {p256dh, auth}}를 그대로 저장). pushEnabled는 건드리지 않는다 -
229+
// on/off는 updatePushEnabled로 별도 관리되는 관심사라, 구독 등록 자체가 알림을
230+
// 자동으로 켜거나 끄지는 않는다(가입 시 기본값 true가 이미 적용되어 있음).
231+
public void updatePushSubscription(String pushEndpoint, String pushP256dhKey, String pushAuthKey) {
232+
this.pushEndpoint = pushEndpoint;
233+
this.pushP256dhKey = pushP256dhKey;
234+
this.pushAuthKey = pushAuthKey;
235+
}
236+
227237
// 마이페이지 > 프로필 수정에서 닉네임/프로필 사진을 갱신한다. 중복 검사는 서비스 계층에서
228238
// 이미 끝낸 값이 들어온다고 가정한다.
229239
public void updateProfile(String nickname, String profileImageUrl) {
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package com.leets.tdd.user.dto;
2+
3+
import jakarta.validation.constraints.NotBlank;
4+
import jakarta.validation.constraints.Size;
5+
6+
/**
7+
* 마이페이지 > 알림 구독 등록 요청. 브라우저 Web Push API의 PushSubscription을 그대로 받는다
8+
* (subscription.endpoint / subscription.toJSON().keys.p256dh / .auth). FCM 토큰이 아니라
9+
* 이 세 값(endpoint + p256dh + auth)을 저장하는 이유는 User 엔티티/DB 컬럼(push_endpoint,
10+
* push_p256dh_key, push_auth_key)이 이미 Web Push 방식으로 설계되어 있기 때문이다.
11+
* 길이 제한은 User 엔티티 컬럼 길이(endpoint 500자, key 255자)와 맞춘다.
12+
*/
13+
public record PushSubscriptionRequest(
14+
15+
@NotBlank(message = "endpoint를 입력해주세요.")
16+
@Size(max = 500, message = "endpoint는 500자 이하로 입력해주세요.")
17+
String endpoint,
18+
19+
@NotBlank(message = "p256dhKey를 입력해주세요.")
20+
@Size(max = 255, message = "p256dhKey는 255자 이하로 입력해주세요.")
21+
String p256dhKey,
22+
23+
@NotBlank(message = "authKey를 입력해주세요.")
24+
@Size(max = 255, message = "authKey는 255자 이하로 입력해주세요.")
25+
String authKey
26+
) {
27+
}

src/main/java/com/leets/tdd/user/exception/UserErrorCode.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ public enum UserErrorCode {
2222
REGISTRATION_BLOCKED(HttpStatus.BAD_REQUEST, "가입할 수 없는 이메일입니다."),
2323
ALREADY_REGISTERED_EMAIL(HttpStatus.BAD_REQUEST, "이미 가입된 이메일입니다."),
2424
NICKNAME_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "닉네임 자동 생성에 실패했습니다. 잠시 후 다시 시도해주세요."),
25+
ACTIVE_POT_EXISTS(HttpStatus.BAD_REQUEST, "진행 중인 배달팟이 있어 탈퇴할 수 없습니다."),
26+
UNSETTLED_POT_EXISTS(HttpStatus.BAD_REQUEST, "정산 중인 배달팟이 있어 탈퇴할 수 없습니다."),
2527
DORM_VERIFICATION_ALREADY_IN_PROGRESS(HttpStatus.BAD_REQUEST, "이미 인증 신청이 진행 중이거나 승인된 상태입니다."),
2628
INVALID_DORM_VERIFICATION_IMAGE(HttpStatus.BAD_REQUEST, "입력값이 올바르지 않습니다."),
2729
DORM_VERIFICATION_UPLOAD_NOT_FOUND(HttpStatus.BAD_REQUEST, "업로드된 이미지를 찾을 수 없습니다. 다시 시도해주세요."),

src/main/java/com/leets/tdd/user/service/UserService.java

Lines changed: 81 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@
66
import com.leets.tdd.global.storage.ImageStorageService;
77
import com.leets.tdd.global.storage.dto.PresignedUploadResponse;
88
import com.leets.tdd.auth.service.EmailVerificationService;
9+
import com.leets.tdd.party.domain.PartyParticipant;
10+
import com.leets.tdd.party.domain.PartyParticipantStatus;
11+
import com.leets.tdd.party.domain.PartyStatus;
12+
import com.leets.tdd.party.repository.DeliveryPartyRepository;
13+
import com.leets.tdd.party.repository.PartyParticipantRepository;
14+
import com.leets.tdd.settlement.domain.SettlementStatus;
915
import com.leets.tdd.user.domain.Dormitory;
1016
import com.leets.tdd.user.domain.User;
1117
import com.leets.tdd.user.dto.DormVerificationConfirmRequest;
@@ -24,6 +30,7 @@
2430
import com.leets.tdd.user.dto.ProfileUpdateResponse;
2531
import com.leets.tdd.user.dto.PushSettingRequest;
2632
import com.leets.tdd.user.dto.PushSettingResponse;
33+
import com.leets.tdd.user.dto.PushSubscriptionRequest;
2734
import com.leets.tdd.user.dto.WithdrawalRequest;
2835
import com.leets.tdd.user.exception.UserErrorCode;
2936
import com.leets.tdd.user.exception.UserException;
@@ -35,6 +42,10 @@
3542
import org.springframework.transaction.annotation.Transactional;
3643

3744
import java.time.LocalDateTime;
45+
import java.util.EnumSet;
46+
import java.util.List;
47+
import java.util.Set;
48+
import java.util.stream.Collectors;
3849

3950
/**
4051
* 마이페이지 조회 + 계정등록(회원가입 완료) API.
@@ -55,8 +66,16 @@ public class UserService {
5566
private final EmailVerificationService emailVerificationService;
5667
private final PasswordEncoder passwordEncoder;
5768
private final NicknameGenerator nicknameGenerator;
69+
private final DeliveryPartyRepository deliveryPartyRepository;
70+
private final PartyParticipantRepository partyParticipantRepository;
5871
private final ImageStorageService imageStorageService;
5972

73+
// 탈퇴 제한: 진행 중인 배달 팟(모집중/마감/주문완료) 또는 완료됐지만 정산이 안 끝난 팟이 있으면 막는다.
74+
private static final Set<PartyStatus> ONGOING_PARTY_STATUSES =
75+
EnumSet.of(PartyStatus.RECRUITING, PartyStatus.CLOSED, PartyStatus.ORDERED);
76+
private static final Set<SettlementStatus> SETTLEMENT_TERMINAL_STATUSES =
77+
EnumSet.of(SettlementStatus.COMPLETED, SettlementStatus.CANCELED);
78+
6079
@Transactional
6180
public MyPageResponse getMyPage(Long userId) {
6281
User user = userRepository.findById(userId)
@@ -274,6 +293,22 @@ public PushSettingResponse updatePushSetting(Long userId, PushSettingRequest req
274293
return new PushSettingResponse(user.isPushEnabled());
275294
}
276295

296+
/**
297+
* 마이페이지 > 알림 구독 등록. 브라우저 PushManager.subscribe()로 발급받은 endpoint/키를
298+
* 저장한다(Web Push 방식, FCM 아님 - User 엔티티 컬럼이 이미 endpoint/p256dh/auth 구조).
299+
* 기존 구독이 있어도 그냥 덮어쓴다(기기 교체/브라우저 재설치 시 재구독하는 흔한 케이스라
300+
* 별도 중복 에러 없이 최신 값으로 갱신). pushEnabled는 건드리지 않는다(updatePushSetting의
301+
* 별도 관심사).
302+
*/
303+
@Transactional
304+
public void registerPushSubscription(Long userId, PushSubscriptionRequest request) {
305+
User user = userRepository.findById(userId)
306+
.orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND));
307+
308+
user.updatePushSubscription(request.endpoint(), request.p256dhKey(), request.authKey());
309+
userRepository.save(user);
310+
}
311+
277312
/**
278313
* 마이페이지 > 비밀번호 수정. 현재 비밀번호로 본인 확인 후 새 비밀번호로 바꾸고,
279314
* 기존 refresh token을 무효화한다(clearRefreshToken - 로그아웃/탈퇴와 동일한 관례).
@@ -303,10 +338,10 @@ public void changePassword(Long userId, ChangePasswordRequest request) {
303338
* suspendedUntil/noShowApprovedCount/mannerTemperature는 softDelete()가 건드리지 않으므로
304339
* 그대로 유지된다(정지 우회 방지 + 재가입 시 이력 복원).
305340
* <p>
306-
* TODO: "진행 중인 배달팟(정산 미완료) 여부 확인" 단계는 의도적으로 뺐다. party 도메인
307-
* 엔티티(DeliveryParty/PartyParticipant) 자체는 이미 있지만, 이 기능을 넣으려면 user 도메인이
308-
* party/settlement 도메인에 의존하게 돼서 이 PR(로그인/로그아웃/탈퇴) 범위를 벗어난다.
309-
* 별도 이슈로 분리해서 처리한다(명세 실패 케이스: "진행 중인 배달팟이 있어 탈퇴할 수 없습니다.").
341+
* 방장/참여자 구분 없이 본인이 속한 팟 중 진행 중(RECRUITING/CLOSED/ORDERED)인 팟이 있으면
342+
* ACTIVE_POT_EXISTS로, COMPLETED인데 정산이 아직 안 끝난(SettlementStatus가 COMPLETED/CANCELED가
343+
* 아닌) 팟이 있으면 UNSETTLED_POT_EXISTS로 탈퇴를 막는다. 방장 탈퇴로 정산 트리거가 사라지는 문제,
344+
* 참여자가 노쇼 신고 전에 탈퇴로 회피하는 문제를 둘 다 막기 위함이다.
310345
*/
311346
@Transactional
312347
public void withdraw(Long userId, WithdrawalRequest request) {
@@ -321,11 +356,53 @@ public void withdraw(Long userId, WithdrawalRequest request) {
321356
throw new UserException(UserErrorCode.PASSWORD_MISMATCH);
322357
}
323358

359+
List<Long> joinedPartyIds = joinedPartyIds(userId);
360+
361+
if (hasOngoingDeliveryParty(userId, joinedPartyIds)) {
362+
throw new UserException(UserErrorCode.ACTIVE_POT_EXISTS);
363+
}
364+
if (hasUnsettledDeliveryParty(userId, joinedPartyIds)) {
365+
throw new UserException(UserErrorCode.UNSETTLED_POT_EXISTS);
366+
}
367+
324368
user.softDelete();
325369
user.clearRefreshToken();
326370
userRepository.save(user);
327371
}
328372

373+
// 참여를 취소하지 않은(JOINED) 팟 id 목록 - 참여자(participant) 기준 체크에 재사용
374+
private List<Long> joinedPartyIds(Long userId) {
375+
return partyParticipantRepository
376+
.findAllByUserIdAndStatus(userId, PartyParticipantStatus.JOINED)
377+
.stream()
378+
.map(PartyParticipant::getPartyId)
379+
.collect(Collectors.toList());
380+
}
381+
382+
// RECRUITING/CLOSED/ORDERED로 진행 중인 팟이 있는지 (방장 + 참여자 기준)
383+
private boolean hasOngoingDeliveryParty(Long userId, List<Long> joinedPartyIds) {
384+
if (deliveryPartyRepository.existsByCreatorIdAndStatusIn(userId, ONGOING_PARTY_STATUSES)) {
385+
return true;
386+
}
387+
if (joinedPartyIds.isEmpty()) {
388+
return false;
389+
}
390+
return deliveryPartyRepository.existsByIdInAndStatusIn(joinedPartyIds, ONGOING_PARTY_STATUSES);
391+
}
392+
393+
// COMPLETED인데 정산이 아직 안 끝난 팟이 있는지 (방장 + 참여자 기준)
394+
private boolean hasUnsettledDeliveryParty(Long userId, List<Long> joinedPartyIds) {
395+
if (deliveryPartyRepository.existsByCreatorIdAndStatusAndSettlementStatusNotIn(
396+
userId, PartyStatus.COMPLETED, SETTLEMENT_TERMINAL_STATUSES)) {
397+
return true;
398+
}
399+
if (joinedPartyIds.isEmpty()) {
400+
return false;
401+
}
402+
return deliveryPartyRepository.existsByIdInAndStatusAndSettlementStatusNotIn(
403+
joinedPartyIds, PartyStatus.COMPLETED, SETTLEMENT_TERMINAL_STATUSES);
404+
}
405+
329406
private record IssuedTokens(String accessToken, String refreshToken, LocalDateTime refreshTokenExpiresAt) {
330407
}
331408

src/test/java/com/leets/tdd/user/controller/UserControllerTest.java

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,49 @@ void withdraw_alreadyDeletedAccountToken_returns401() throws Exception {
161161
verify(userService, never()).withdraw(any(), any());
162162
}
163163

164+
private static final String SUBSCRIPTION_BODY =
165+
"{\"endpoint\":\"https://fcm.googleapis.com/endpoint\",\"p256dhKey\":\"p256dh-key\",\"authKey\":\"auth-key\"}";
166+
167+
@Test
168+
@DisplayName("토큰 없이 구독 등록을 요청하면 401을 반환하고 서비스가 호출되지 않는다")
169+
void registerPushSubscription_withoutToken_returns401() throws Exception {
170+
mockMvc.perform(post("/api/v1/users/me/push-subscription")
171+
.contentType("application/json")
172+
.content(SUBSCRIPTION_BODY))
173+
.andExpect(status().isUnauthorized());
174+
175+
verify(userService, never()).registerPushSubscription(any(), any());
176+
}
177+
178+
@Test
179+
@DisplayName("유효한 토큰이면 구독 등록에 성공한다")
180+
void registerPushSubscription_success_returns200() throws Exception {
181+
stubValidToken("valid-token", 1L, UserStatus.ACTIVE);
182+
183+
mockMvc.perform(post("/api/v1/users/me/push-subscription")
184+
.header("Authorization", "Bearer valid-token")
185+
.contentType("application/json")
186+
.content(SUBSCRIPTION_BODY))
187+
.andExpect(status().isOk())
188+
.andExpect(jsonPath("$.success").value(true));
189+
190+
verify(userService).registerPushSubscription(eq(1L), any());
191+
}
192+
193+
@Test
194+
@DisplayName("endpoint가 비어있으면 400을 반환한다")
195+
void registerPushSubscription_blankEndpoint_returns400() throws Exception {
196+
stubValidToken("valid-token", 1L, UserStatus.ACTIVE);
197+
198+
mockMvc.perform(post("/api/v1/users/me/push-subscription")
199+
.header("Authorization", "Bearer valid-token")
200+
.contentType("application/json")
201+
.content("{\"endpoint\":\"\",\"p256dhKey\":\"p256dh-key\",\"authKey\":\"auth-key\"}"))
202+
.andExpect(status().isBadRequest());
203+
204+
verify(userService, never()).registerPushSubscription(any(), any());
205+
}
206+
164207
@Test
165208
@DisplayName("profileImageUrl에 빈 값이 아닌 값을 넣으면 400을 반환하고 서비스가 호출되지 않는다")
166209
void updateProfile_withNonEmptyProfileImageUrl_returns400() throws Exception {

0 commit comments

Comments
 (0)