Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package com.leets.tdd.party.repository;

import com.leets.tdd.party.domain.DeliveryParty;
import com.leets.tdd.party.domain.PartyStatus;
import com.leets.tdd.settlement.domain.SettlementStatus;
import jakarta.persistence.LockModeType;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
Expand All @@ -20,4 +22,22 @@ List<DeliveryParty> findAllByCreatorIdAndSettlementStatus(
Long creatorId,
SettlementStatus settlementStatus
);

// 탈퇴 제한: 방장(creator)으로서 진행 중이거나(RECRUITING/CLOSED/ORDERED) 정산이 안 끝난 팟이 있는지 확인
boolean existsByCreatorIdAndStatusIn(Long creatorId, Collection<PartyStatus> statuses);

boolean existsByCreatorIdAndStatusAndSettlementStatusNotIn(
Long creatorId,
PartyStatus status,
Collection<SettlementStatus> settlementStatuses
);

// 탈퇴 제한: 참여자(participant)로서 진행 중이거나 정산이 안 끝난 팟이 있는지 확인
boolean existsByIdInAndStatusIn(Collection<Long> ids, Collection<PartyStatus> statuses);

boolean existsByIdInAndStatusAndSettlementStatusNotIn(
Collection<Long> ids,
PartyStatus status,
Collection<SettlementStatus> settlementStatuses
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,7 @@ public interface PartyParticipantRepository extends JpaRepository<PartyParticipa
List<PartyParticipant> findAllByPartyIdIn(Collection<Long> partyIds);

List<PartyParticipant> findAllByUserIdAndPaymentStatusIsNotNull(Long userId);

// 탈퇴 제한: 이 사용자가 참여 중인(취소하지 않은) 팟 목록
List<PartyParticipant> findAllByUserIdAndStatus(Long userId, PartyParticipantStatus status);
}
18 changes: 18 additions & 0 deletions src/main/java/com/leets/tdd/user/controller/UserController.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
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.service.UserService;
import io.swagger.v3.oas.annotations.Operation;
Expand Down Expand Up @@ -176,6 +177,23 @@ public ResponseEntity<ApiResponse<PushSettingResponse>> updatePushSetting(
return ResponseEntity.ok(ApiResponse.success("알림 설정이 변경되었습니다.", response));
}

@Operation(
summary = "알림 구독 등록",
description = "브라우저 Web Push 구독 정보(endpoint/p256dhKey/authKey)를 저장한다. "
+ "이미 등록된 구독이 있으면 최신 값으로 덮어쓴다(기기 교체/재설치 시 재구독 케이스). "
+ "알림 on/off 자체는 이 API가 아니라 /me/push-setting으로 따로 관리한다. "
+ "Authorization 헤더에 access token(Bearer)이 필요하다."
)
@SecurityRequirement(name = "bearerAuth")
@PostMapping("/me/push-subscription")
public ResponseEntity<ApiResponse<Void>> registerPushSubscription(
@AuthenticationPrincipal UserPrincipal userPrincipal,
@Valid @RequestBody PushSubscriptionRequest request
) {
userService.registerPushSubscription(userPrincipal.userId(), request);
return ResponseEntity.ok(ApiResponse.success("구독 정보가 저장되었습니다."));
}

@Operation(
summary = "비밀번호 수정",
description = "현재 비밀번호를 재확인한 뒤 새 비밀번호로 변경한다(기존과 동일한 비밀번호는 불가). "
Expand Down
10 changes: 10 additions & 0 deletions src/main/java/com/leets/tdd/user/domain/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,16 @@ public void updatePushEnabled(boolean pushEnabled) {
this.pushEnabled = pushEnabled;
}

// Web Push 구독 등록/갱신에 사용(브라우저 PushManager.subscribe()가 돌려주는
// {endpoint, keys: {p256dh, auth}}를 그대로 저장). pushEnabled는 건드리지 않는다 -
// on/off는 updatePushEnabled로 별도 관리되는 관심사라, 구독 등록 자체가 알림을
// 자동으로 켜거나 끄지는 않는다(가입 시 기본값 true가 이미 적용되어 있음).
public void updatePushSubscription(String pushEndpoint, String pushP256dhKey, String pushAuthKey) {
this.pushEndpoint = pushEndpoint;
this.pushP256dhKey = pushP256dhKey;
this.pushAuthKey = pushAuthKey;
}

// 마이페이지 > 프로필 수정에서 닉네임/프로필 사진을 갱신한다. 중복 검사는 서비스 계층에서
// 이미 끝낸 값이 들어온다고 가정한다.
public void updateProfile(String nickname, String profileImageUrl) {
Expand Down
27 changes: 27 additions & 0 deletions src/main/java/com/leets/tdd/user/dto/PushSubscriptionRequest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package com.leets.tdd.user.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

/**
* 마이페이지 > 알림 구독 등록 요청. 브라우저 Web Push API의 PushSubscription을 그대로 받는다
* (subscription.endpoint / subscription.toJSON().keys.p256dh / .auth). FCM 토큰이 아니라
* 이 세 값(endpoint + p256dh + auth)을 저장하는 이유는 User 엔티티/DB 컬럼(push_endpoint,
* push_p256dh_key, push_auth_key)이 이미 Web Push 방식으로 설계되어 있기 때문이다.
* 길이 제한은 User 엔티티 컬럼 길이(endpoint 500자, key 255자)와 맞춘다.
*/
public record PushSubscriptionRequest(

@NotBlank(message = "endpoint를 입력해주세요.")
@Size(max = 500, message = "endpoint는 500자 이하로 입력해주세요.")
String endpoint,

@NotBlank(message = "p256dhKey를 입력해주세요.")
@Size(max = 255, message = "p256dhKey는 255자 이하로 입력해주세요.")
String p256dhKey,

@NotBlank(message = "authKey를 입력해주세요.")
@Size(max = 255, message = "authKey는 255자 이하로 입력해주세요.")
String authKey
) {
}
2 changes: 2 additions & 0 deletions src/main/java/com/leets/tdd/user/exception/UserErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ public enum UserErrorCode {
REGISTRATION_BLOCKED(HttpStatus.BAD_REQUEST, "가입할 수 없는 이메일입니다."),
ALREADY_REGISTERED_EMAIL(HttpStatus.BAD_REQUEST, "이미 가입된 이메일입니다."),
NICKNAME_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "닉네임 자동 생성에 실패했습니다. 잠시 후 다시 시도해주세요."),
ACTIVE_POT_EXISTS(HttpStatus.BAD_REQUEST, "진행 중인 배달팟이 있어 탈퇴할 수 없습니다."),
UNSETTLED_POT_EXISTS(HttpStatus.BAD_REQUEST, "정산 중인 배달팟이 있어 탈퇴할 수 없습니다."),
DORM_VERIFICATION_ALREADY_IN_PROGRESS(HttpStatus.BAD_REQUEST, "이미 인증 신청이 진행 중이거나 승인된 상태입니다."),
INVALID_DORM_VERIFICATION_IMAGE(HttpStatus.BAD_REQUEST, "입력값이 올바르지 않습니다."),
DORM_VERIFICATION_UPLOAD_NOT_FOUND(HttpStatus.BAD_REQUEST, "업로드된 이미지를 찾을 수 없습니다. 다시 시도해주세요."),
Expand Down
85 changes: 81 additions & 4 deletions src/main/java/com/leets/tdd/user/service/UserService.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@
import com.leets.tdd.global.storage.ImageStorageService;
import com.leets.tdd.global.storage.dto.PresignedUploadResponse;
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.DormVerificationConfirmRequest;
Expand All @@ -24,6 +30,7 @@
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;
Expand All @@ -35,6 +42,10 @@
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.
Expand All @@ -55,8 +66,16 @@ public class UserService {
private final EmailVerificationService emailVerificationService;
private final PasswordEncoder passwordEncoder;
private final NicknameGenerator nicknameGenerator;
private final DeliveryPartyRepository deliveryPartyRepository;
private final PartyParticipantRepository partyParticipantRepository;
private final ImageStorageService imageStorageService;

// 탈퇴 제한: 진행 중인 배달 팟(모집중/마감/주문완료) 또는 완료됐지만 정산이 안 끝난 팟이 있으면 막는다.
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)
Expand Down Expand Up @@ -274,6 +293,22 @@ public PushSettingResponse updatePushSetting(Long userId, PushSettingRequest req
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 - 로그아웃/탈퇴와 동일한 관례).
Expand Down Expand Up @@ -303,10 +338,10 @@ public void changePassword(Long userId, ChangePasswordRequest request) {
* suspendedUntil/noShowApprovedCount/mannerTemperature는 softDelete()가 건드리지 않으므로
* 그대로 유지된다(정지 우회 방지 + 재가입 시 이력 복원).
* <p>
* TODO: "진행 중인 배달팟(정산 미완료) 여부 확인" 단계는 의도적으로 뺐다. party 도메인
* 엔티티(DeliveryParty/PartyParticipant) 자체는 이미 있지만, 이 기능을 넣으려면 user 도메인이
* party/settlement 도메인에 의존하게 돼서 이 PR(로그인/로그아웃/탈퇴) 범위를 벗어난다.
* 별도 이슈로 분리해서 처리한다(명세 실패 케이스: "진행 중인 배달팟이 있어 탈퇴할 수 없습니다.").
* 방장/참여자 구분 없이 본인이 속한 팟 중 진행 중(RECRUITING/CLOSED/ORDERED)인 팟이 있으면
* ACTIVE_POT_EXISTS로, COMPLETED인데 정산이 아직 안 끝난(SettlementStatus가 COMPLETED/CANCELED가
* 아닌) 팟이 있으면 UNSETTLED_POT_EXISTS로 탈퇴를 막는다. 방장 탈퇴로 정산 트리거가 사라지는 문제,
* 참여자가 노쇼 신고 전에 탈퇴로 회피하는 문제를 둘 다 막기 위함이다.
*/
@Transactional
public void withdraw(Long userId, WithdrawalRequest request) {
Expand All @@ -321,11 +356,53 @@ public void withdraw(Long userId, WithdrawalRequest request) {
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) {
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,49 @@ void withdraw_alreadyDeletedAccountToken_returns401() throws Exception {
verify(userService, never()).withdraw(any(), any());
}

private static final String SUBSCRIPTION_BODY =
"{\"endpoint\":\"https://fcm.googleapis.com/endpoint\",\"p256dhKey\":\"p256dh-key\",\"authKey\":\"auth-key\"}";

@Test
@DisplayName("토큰 없이 구독 등록을 요청하면 401을 반환하고 서비스가 호출되지 않는다")
void registerPushSubscription_withoutToken_returns401() throws Exception {
mockMvc.perform(post("/api/v1/users/me/push-subscription")
.contentType("application/json")
.content(SUBSCRIPTION_BODY))
.andExpect(status().isUnauthorized());

verify(userService, never()).registerPushSubscription(any(), any());
}

@Test
@DisplayName("유효한 토큰이면 구독 등록에 성공한다")
void registerPushSubscription_success_returns200() throws Exception {
stubValidToken("valid-token", 1L, UserStatus.ACTIVE);

mockMvc.perform(post("/api/v1/users/me/push-subscription")
.header("Authorization", "Bearer valid-token")
.contentType("application/json")
.content(SUBSCRIPTION_BODY))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true));

verify(userService).registerPushSubscription(eq(1L), any());
}

@Test
@DisplayName("endpoint가 비어있으면 400을 반환한다")
void registerPushSubscription_blankEndpoint_returns400() throws Exception {
stubValidToken("valid-token", 1L, UserStatus.ACTIVE);

mockMvc.perform(post("/api/v1/users/me/push-subscription")
.header("Authorization", "Bearer valid-token")
.contentType("application/json")
.content("{\"endpoint\":\"\",\"p256dhKey\":\"p256dh-key\",\"authKey\":\"auth-key\"}"))
.andExpect(status().isBadRequest());

verify(userService, never()).registerPushSubscription(any(), any());
}

@Test
@DisplayName("profileImageUrl에 빈 값이 아닌 값을 넣으면 400을 반환하고 서비스가 호출되지 않는다")
void updateProfile_withNonEmptyProfileImageUrl_returns400() throws Exception {
Expand Down
Loading
Loading