diff --git a/.github/workflows/cd-dev.yml b/.github/workflows/cd-dev.yml index dc0ba501..3312f967 100644 --- a/.github/workflows/cd-dev.yml +++ b/.github/workflows/cd-dev.yml @@ -1,6 +1,7 @@ name: CI/CD using github actions & docker on: + workflow_dispatch: push: branches: - develop @@ -53,10 +54,6 @@ jobs: needs: push_to_registry runs-on: ubuntu-latest steps: - - name: Get Github action IP - id: ip - uses: haythem/public-ip@v1.2 - - name: Deploy to prod if: contains(github.ref, 'develop') || contains(github.ref, 'main') uses: appleboy/ssh-action@master diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 199d0ce8..cf73b241 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -56,10 +56,6 @@ jobs: needs: push_to_registry runs-on: ubuntu-latest steps: - - name: Get Github action IP - id: ip - uses: haythem/public-ip@v1.2 - - name: Deploy to prod if: contains(github.ref, 'develop') || contains(github.ref, 'main') uses: appleboy/ssh-action@master diff --git a/.gitignore b/.gitignore index 9b084d21..4055adb4 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,8 @@ out/ ###claude### .claude .serena + +# Local-only config +/src/main/resources/application-local.yml +/.env.local +/docker-compose.local.yml \ No newline at end of file diff --git a/src/main/java/devkor/com/teamcback/domain/ble/event/PlaceBecameVacantEvent.java b/src/main/java/devkor/com/teamcback/domain/ble/event/PlaceBecameVacantEvent.java new file mode 100644 index 00000000..ae3ca230 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/ble/event/PlaceBecameVacantEvent.java @@ -0,0 +1,10 @@ +package devkor.com.teamcback.domain.ble.event; + +import java.time.LocalDateTime; + +public record PlaceBecameVacantEvent( + Long placeId, + Long bleDataId, + LocalDateTime occurredAt +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/ble/repository/BLEDataRepository.java b/src/main/java/devkor/com/teamcback/domain/ble/repository/BLEDataRepository.java index 5e3c6854..3ca5fb51 100644 --- a/src/main/java/devkor/com/teamcback/domain/ble/repository/BLEDataRepository.java +++ b/src/main/java/devkor/com/teamcback/domain/ble/repository/BLEDataRepository.java @@ -11,5 +11,6 @@ public interface BLEDataRepository extends JpaRepository { Optional findTopByDeviceOrderByLastTimeDesc(BLEDevice device); + Optional findTopByDeviceOrderByLastTimeDescIdDesc(BLEDevice device); List findAllByDeviceAndLastTimeBetweenOrderByLastTimeAsc(BLEDevice device, LocalDateTime start, LocalDateTime end); } diff --git a/src/main/java/devkor/com/teamcback/domain/ble/service/BLEService.java b/src/main/java/devkor/com/teamcback/domain/ble/service/BLEService.java index ecddca8b..d5a876f7 100644 --- a/src/main/java/devkor/com/teamcback/domain/ble/service/BLEService.java +++ b/src/main/java/devkor/com/teamcback/domain/ble/service/BLEService.java @@ -9,6 +9,7 @@ import devkor.com.teamcback.domain.ble.entity.BLEData; import devkor.com.teamcback.domain.ble.entity.BLEDevice; import devkor.com.teamcback.domain.ble.entity.BLEstatus; +import devkor.com.teamcback.domain.ble.event.PlaceBecameVacantEvent; import devkor.com.teamcback.domain.ble.repository.BLEDataRepository; import devkor.com.teamcback.domain.ble.repository.BLEDeviceRepository; import devkor.com.teamcback.domain.place.entity.Place; @@ -17,6 +18,7 @@ import devkor.com.teamcback.global.response.ResultCode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -34,6 +36,7 @@ public class BLEService { private final BLEDeviceRepository bledeviceRepository; private final BLEDataRepository bleDataRepository; private final PlaceRepository placeRepository; + private final ApplicationEventPublisher eventPublisher; // 평균 구해올 시간대 라벨 private static final int[] TIME_SLOTS = {7, 10, 13, 16, 19, 22}; @@ -46,6 +49,11 @@ public class BLEService { @Transactional public UpdateBLERes updateBLE(UpdateBLEReq updateBLEReq) { BLEDevice bleDevice = bledeviceRepository.findByDeviceName(updateBLEReq.getDeviceName()); + if (bleDevice == null) { + throw new GlobalException(ResultCode.NOT_FOUND_DEVICE_NAME); + } + BLEData previousData = bleDataRepository.findTopByDeviceOrderByLastTimeDescIdDesc(bleDevice) + .orElse(null); int capacity = bleDevice.getCapacity(); int people = getBlEPeople(updateBLEReq.getLastCount(), bleDevice); double final_ratio = (double) people / capacity; @@ -61,9 +69,29 @@ public UpdateBLERes updateBLE(UpdateBLEReq updateBLEReq) { bleData.setLastTime(updateBLEReq.getLastTime()); bleDataRepository.save(bleData); + if (becameVacant(previousData, status) && bleDevice.getPlace() != null) { + eventPublisher.publishEvent(new PlaceBecameVacantEvent( + bleDevice.getPlace().getId(), + bleData.getId(), + bleData.getLastTime() + )); + } + return new UpdateBLERes(bleData); } + private boolean becameVacant( + BLEData previousData, + BLEstatus newStatus + ) { + if (!BLEstatus.VACANT.equals(newStatus) || previousData == null) { + return false; + } + + return BLEstatus.AVAILABLE.equals(previousData.getLastStatus()) + || BLEstatus.CROWDED.equals(previousData.getLastStatus()); + } + private int getBlEPeople(int lastCount, BLEDevice bleDevice) { if (bleDevice == null) throw new GlobalException(ResultCode.NOT_FOUND_DEVICE_NAME); double ratio = bleDevice.getRatio(); diff --git a/src/main/java/devkor/com/teamcback/domain/bookmark/repository/CategoryRepository.java b/src/main/java/devkor/com/teamcback/domain/bookmark/repository/CategoryRepository.java index 3051eaa1..aa5a3ff7 100644 --- a/src/main/java/devkor/com/teamcback/domain/bookmark/repository/CategoryRepository.java +++ b/src/main/java/devkor/com/teamcback/domain/bookmark/repository/CategoryRepository.java @@ -27,4 +27,15 @@ List findCategoriesByUserAndLocationTypeAndLocationId( @Query("SELECT c FROM Category c LEFT JOIN FETCH c.categoryBookmarkList WHERE c.user = :user") List findByUser(@Param("user") User user); + + @Query(""" + SELECT DISTINCT c.user.userId FROM CategoryBookmark cb + JOIN cb.category c + JOIN cb.bookmark b + WHERE c.user IS NOT NULL AND b.locationType = :locationType AND b.locationId = :locationId + """) + List findDistinctUserIdsByLocationTypeAndLocationId( + @Param("locationType") LocationType locationType, + @Param("locationId") Long locationId + ); } diff --git a/src/main/java/devkor/com/teamcback/domain/character/controller/AdminStoreController.java b/src/main/java/devkor/com/teamcback/domain/character/controller/AdminStoreController.java new file mode 100644 index 00000000..87a76219 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/controller/AdminStoreController.java @@ -0,0 +1,124 @@ +package devkor.com.teamcback.domain.character.controller; + +import devkor.com.teamcback.domain.character.dto.request.CreateCharacterReq; +import devkor.com.teamcback.domain.character.dto.request.ModifyCharacterReq; +import devkor.com.teamcback.domain.character.dto.response.CreateCharacterRes; +import devkor.com.teamcback.domain.character.dto.response.DeleteCharacterRes; +import devkor.com.teamcback.domain.character.dto.response.GetAdminCharacterListRes; +import devkor.com.teamcback.domain.character.dto.response.GrantCharacterRes; +import devkor.com.teamcback.domain.character.dto.response.ModifyCharacterRes; +import devkor.com.teamcback.domain.character.service.AdminStoreService; +import devkor.com.teamcback.global.response.CommonResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/admin/store") +public class AdminStoreController { + private final AdminStoreService adminStoreService; + + /** + * 캐릭터 목록 조회 (비활성 포함) + */ + @Operation(summary = "관리자 캐릭터 목록 조회", description = "비활성 캐릭터를 포함한 전체 캐릭터 조회") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + }) + @GetMapping("") + public CommonResponse getCharacterList() { + return CommonResponse.success(adminStoreService.getCharacterList()); + } + + /** + * 캐릭터 생성 + * @param req 캐릭터 정보 (이미지, 가격 포함) + */ + @Operation(summary = "캐릭터 생성", description = "캐릭터 생성 (이미지 필수, 가격은 0 이상의 포인트)") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "400", description = "잘못된 입력", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + @PostMapping(value = "", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public CommonResponse createCharacter( + @Parameter(description = "캐릭터 정보") + @ModelAttribute CreateCharacterReq req) { + return CommonResponse.success(adminStoreService.createCharacter(req)); + } + + /** + * 캐릭터 수정 + * @param characterId 캐릭터 ID + * @param req 캐릭터 정보 (이미지 미첨부 시 기존 이미지 유지) + */ + @Operation(summary = "캐릭터 수정", description = "캐릭터 수정 (이미지 미첨부 시 기존 이미지 유지)") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "404", description = "Not Found", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + @PutMapping(value = "/{characterId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public CommonResponse modifyCharacter( + @Parameter(description = "캐릭터 ID", example = "1") + @PathVariable(name = "characterId") Long characterId, + @Parameter(description = "캐릭터 정보") + @ModelAttribute ModifyCharacterReq req) { + return CommonResponse.success(adminStoreService.modifyCharacter(characterId, req)); + } + + /** + * 캐릭터 삭제 + * @param characterId 캐릭터 ID + */ + @Operation(summary = "캐릭터 삭제", description = "캐릭터 삭제 (구매한 사용자가 있으면 삭제 불가, isActive=false로 숨김 처리 권장)") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "404", description = "Not Found", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + @ApiResponse(responseCode = "409", description = "구매한 사용자가 있는 캐릭터", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + @DeleteMapping("/{characterId}") + public CommonResponse deleteCharacter( + @Parameter(description = "캐릭터 ID", example = "1") + @PathVariable(name = "characterId") Long characterId) { + return CommonResponse.success(adminStoreService.deleteCharacter(characterId)); + } + + /** + * 캐릭터 수동 지급 + * @param characterId 캐릭터 ID + * @param userId 사용자 ID + */ + @Operation(summary = "캐릭터 수동 지급", description = "특정 사용자에게 캐릭터 지급 (이벤트 보상용, 포인트 차감 없음)") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "404", description = "Not Found", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + @ApiResponse(responseCode = "409", description = "이미 보유한 캐릭터", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + @PostMapping("/{characterId}/grant/{userId}") + public CommonResponse grantCharacter( + @Parameter(description = "캐릭터 ID", example = "1") + @PathVariable(name = "characterId") Long characterId, + @Parameter(description = "사용자 ID", example = "1") + @PathVariable(name = "userId") Long userId) { + return CommonResponse.success(adminStoreService.grantCharacter(characterId, userId)); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/controller/StoreController.java b/src/main/java/devkor/com/teamcback/domain/character/controller/StoreController.java new file mode 100644 index 00000000..d745a92c --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/controller/StoreController.java @@ -0,0 +1,129 @@ +package devkor.com.teamcback.domain.character.controller; + +import devkor.com.teamcback.domain.character.dto.response.EquipCharacterRes; +import devkor.com.teamcback.domain.character.dto.response.GetMyCharacterListRes; +import devkor.com.teamcback.domain.character.dto.response.GetStoreRes; +import devkor.com.teamcback.domain.character.dto.response.PurchaseCharacterRes; +import devkor.com.teamcback.domain.character.dto.response.UnequipCharacterRes; +import devkor.com.teamcback.domain.character.service.StoreService; +import devkor.com.teamcback.global.response.CommonResponse; +import devkor.com.teamcback.global.security.UserDetailsImpl; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/store") +public class StoreController { + private final StoreService storeService; + + /** + * 스토어 조회 (보유 포인트 + 캐릭터 목록) + * @param userDetail 사용자 정보 + */ + @Operation(summary = "스토어 조회", description = "보유 포인트와 전체 캐릭터 목록(보유/구매 가능/포인트 부족) 조회") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "404", description = "Not Found", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + @GetMapping("") + public CommonResponse getStore( + @Parameter(description = "사용자 정보") + @AuthenticationPrincipal UserDetailsImpl userDetail) { + return CommonResponse.success(storeService.getStore(userDetail.getUser().getUserId())); + } + + /** + * 내 보유 캐릭터 목록 조회 + * @param userDetail 사용자 정보 + */ + @Operation(summary = "보유 캐릭터 목록 조회", description = "보유 포인트, 구매한 캐릭터, 대표 장착 캐릭터 조회") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "404", description = "Not Found", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + @GetMapping("/my") + public CommonResponse getMyCharacters( + @Parameter(description = "사용자 정보") + @AuthenticationPrincipal UserDetailsImpl userDetail) { + return CommonResponse.success(storeService.getMyCharacters(userDetail.getUser().getUserId())); + } + + /** + * 캐릭터 구매 + * @param userDetail 사용자 정보 + * @param characterId 캐릭터 ID + */ + @Operation(summary = "캐릭터 구매", description = "보유 포인트를 차감하여 캐릭터 구매") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "400", description = "포인트 부족 또는 비활성 캐릭터", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + @ApiResponse(responseCode = "404", description = "Not Found", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + @ApiResponse(responseCode = "409", description = "이미 보유한 캐릭터", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + @PostMapping("/{characterId}/purchase") + public CommonResponse purchaseCharacter( + @Parameter(description = "사용자 정보") + @AuthenticationPrincipal UserDetailsImpl userDetail, + @Parameter(description = "캐릭터 ID", example = "1") + @PathVariable(name = "characterId") Long characterId) { + return CommonResponse.success(storeService.purchaseCharacter(userDetail.getUser().getUserId(), characterId)); + } + + /** + * 대표 캐릭터 장착 + * @param userDetail 사용자 정보 + * @param characterId 캐릭터 ID + */ + @Operation(summary = "대표 캐릭터 장착", description = "구매한 캐릭터를 대표 캐릭터로 장착") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "400", description = "보유하지 않은 캐릭터", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + @ApiResponse(responseCode = "404", description = "Not Found", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + @PutMapping("/{characterId}/equip") + public CommonResponse equipCharacter( + @Parameter(description = "사용자 정보") + @AuthenticationPrincipal UserDetailsImpl userDetail, + @Parameter(description = "캐릭터 ID", example = "1") + @PathVariable(name = "characterId") Long characterId) { + return CommonResponse.success(storeService.equipCharacter(userDetail.getUser().getUserId(), characterId)); + } + + /** + * 대표 캐릭터 장착 해제 + * @param userDetail 사용자 정보 + */ + @Operation(summary = "대표 캐릭터 장착 해제", description = "대표 캐릭터 장착 해제") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 처리 되었습니다."), + @ApiResponse(responseCode = "404", description = "Not Found", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + }) + @DeleteMapping("/equip") + public CommonResponse unequipCharacter( + @Parameter(description = "사용자 정보") + @AuthenticationPrincipal UserDetailsImpl userDetail) { + return CommonResponse.success(storeService.unequipCharacter(userDetail.getUser().getUserId())); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/request/CreateCharacterReq.java b/src/main/java/devkor/com/teamcback/domain/character/dto/request/CreateCharacterReq.java new file mode 100644 index 00000000..2b4f2e27 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/dto/request/CreateCharacterReq.java @@ -0,0 +1,35 @@ +package devkor.com.teamcback.domain.character.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; +import org.springframework.web.multipart.MultipartFile; + +@Schema(description = "저장할 캐릭터 정보") +@Getter +@Setter +public class CreateCharacterReq { + @Schema(description = "캐릭터 이름", example = "아기 호랑이") + private String name; + + @Schema(description = "캐릭터 설명", example = "10 포인트로 구매할 수 있는 캐릭터") + private String description; + + @Schema(description = "캐릭터 대사", example = "같이 캠퍼스를 누벼볼까?") + private String quote; + + @Schema(description = "구매 가격 (포인트)", example = "10") + private Integer price; + + @Schema(description = "해금 레벨 (1~5, 1이면 제한 없음)", example = "2") + private Integer requiredLevel = 1; + + @Schema(description = "정렬 순서", example = "1") + private Integer displayOrder = 0; + + @Schema(description = "노출 여부", example = "true") + private boolean isActive = true; + + @Schema(description = "캐릭터 이미지") + private MultipartFile image; +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/request/ModifyCharacterReq.java b/src/main/java/devkor/com/teamcback/domain/character/dto/request/ModifyCharacterReq.java new file mode 100644 index 00000000..9b74723c --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/dto/request/ModifyCharacterReq.java @@ -0,0 +1,35 @@ +package devkor.com.teamcback.domain.character.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; +import org.springframework.web.multipart.MultipartFile; + +@Schema(description = "수정할 캐릭터 정보") +@Getter +@Setter +public class ModifyCharacterReq { + @Schema(description = "캐릭터 이름", example = "아기 호랑이") + private String name; + + @Schema(description = "캐릭터 설명", example = "10 포인트로 구매할 수 있는 캐릭터") + private String description; + + @Schema(description = "캐릭터 대사", example = "같이 캠퍼스를 누벼볼까?") + private String quote; + + @Schema(description = "구매 가격 (포인트)", example = "10") + private Integer price; + + @Schema(description = "해금 레벨 (1~5, 1이면 제한 없음)", example = "2") + private Integer requiredLevel = 1; + + @Schema(description = "정렬 순서", example = "1") + private Integer displayOrder = 0; + + @Schema(description = "노출 여부", example = "true") + private boolean isActive = true; + + @Schema(description = "캐릭터 이미지 (미첨부 시 기존 이미지 유지)") + private MultipartFile image; +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/CreateCharacterRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/CreateCharacterRes.java new file mode 100644 index 00000000..07d3aca0 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/dto/response/CreateCharacterRes.java @@ -0,0 +1,15 @@ +package devkor.com.teamcback.domain.character.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +@Schema(description = "캐릭터 생성 결과") +@Getter +public class CreateCharacterRes { + @Schema(description = "생성된 캐릭터 ID", example = "1") + private Long characterId; + + public CreateCharacterRes(Long characterId) { + this.characterId = characterId; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/DeleteCharacterRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/DeleteCharacterRes.java new file mode 100644 index 00000000..fb5df029 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/dto/response/DeleteCharacterRes.java @@ -0,0 +1,7 @@ +package devkor.com.teamcback.domain.character.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "캐릭터 삭제 결과") +public class DeleteCharacterRes { +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/EquipCharacterRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/EquipCharacterRes.java new file mode 100644 index 00000000..604091b7 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/dto/response/EquipCharacterRes.java @@ -0,0 +1,15 @@ +package devkor.com.teamcback.domain.character.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +@Schema(description = "대표 캐릭터 장착 결과") +@Getter +public class EquipCharacterRes { + @Schema(description = "장착한 캐릭터 ID", example = "1") + private Long characterId; + + public EquipCharacterRes(Long characterId) { + this.characterId = characterId; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetAdminCharacterListRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetAdminCharacterListRes.java new file mode 100644 index 00000000..5715e38d --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetAdminCharacterListRes.java @@ -0,0 +1,16 @@ +package devkor.com.teamcback.domain.character.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import lombok.Getter; + +@Schema(description = "관리자용 캐릭터 목록") +@Getter +public class GetAdminCharacterListRes { + @Schema(description = "캐릭터 목록") + private List characterList; + + public GetAdminCharacterListRes(List characterList) { + this.characterList = characterList; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetAdminCharacterRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetAdminCharacterRes.java new file mode 100644 index 00000000..a4195d80 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetAdminCharacterRes.java @@ -0,0 +1,44 @@ +package devkor.com.teamcback.domain.character.dto.response; + +import devkor.com.teamcback.domain.character.entity.KoCharacter; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import lombok.Getter; + +@Schema(description = "관리자용 캐릭터 정보") +@Getter +public class GetAdminCharacterRes { + @Schema(description = "캐릭터 ID", example = "1") + private Long characterId; + @Schema(description = "캐릭터 이름", example = "아기 호랑이") + private String name; + @Schema(description = "캐릭터 설명") + private String description; + @Schema(description = "캐릭터 대사") + private String quote; + @Schema(description = "캐릭터 이미지 URL") + private String imageUrl; + @Schema(description = "구매 가격 (포인트)", example = "10") + private Integer price; + @Schema(description = "해금 레벨", example = "2") + private Integer requiredLevel; + @Schema(description = "정렬 순서", example = "1") + private Integer displayOrder; + @Schema(description = "노출 여부", example = "true") + private boolean isActive; + @Schema(description = "생성 일시") + private LocalDateTime createdAt; + + public GetAdminCharacterRes(KoCharacter character) { + this.characterId = character.getCharacterId(); + this.name = character.getName(); + this.description = character.getDescription(); + this.quote = character.getQuote(); + this.imageUrl = character.getImageUrl(); + this.price = character.getPrice(); + this.requiredLevel = character.getRequiredLevel(); + this.displayOrder = character.getDisplayOrder(); + this.isActive = character.isActive(); + this.createdAt = character.getCreatedAt(); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetMyCharacterListRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetMyCharacterListRes.java new file mode 100644 index 00000000..912995a8 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetMyCharacterListRes.java @@ -0,0 +1,22 @@ +package devkor.com.teamcback.domain.character.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import lombok.Getter; + +@Schema(description = "보유 캐릭터 목록") +@Getter +public class GetMyCharacterListRes { + @Schema(description = "보유 포인트", example = "25") + private Long point; + @Schema(description = "대표 장착 캐릭터 ID (미장착 시 null)", example = "1") + private Long equippedCharacterId; + @Schema(description = "보유 캐릭터 목록") + private List characterList; + + public GetMyCharacterListRes(Long point, Long equippedCharacterId, List characterList) { + this.point = point; + this.equippedCharacterId = equippedCharacterId; + this.characterList = characterList; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetMyCharacterRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetMyCharacterRes.java new file mode 100644 index 00000000..cfe4fbcd --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetMyCharacterRes.java @@ -0,0 +1,32 @@ +package devkor.com.teamcback.domain.character.dto.response; + +import devkor.com.teamcback.domain.character.entity.UserCharacter; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import lombok.Getter; + +@Schema(description = "보유 캐릭터 정보") +@Getter +public class GetMyCharacterRes { + @Schema(description = "캐릭터 ID", example = "1") + private Long characterId; + @Schema(description = "캐릭터 이름", example = "아기 호랑이") + private String name; + @Schema(description = "캐릭터 대사", example = "같이 캠퍼스를 누벼볼까?") + private String quote; + @Schema(description = "캐릭터 이미지 URL") + private String imageUrl; + @Schema(description = "구매 일시") + private LocalDateTime purchasedAt; + @Schema(description = "대표 장착 여부", example = "true") + private boolean isEquipped; + + public GetMyCharacterRes(UserCharacter userCharacter, boolean isEquipped) { + this.characterId = userCharacter.getCharacter().getCharacterId(); + this.name = userCharacter.getCharacter().getName(); + this.quote = userCharacter.getCharacter().getQuote(); + this.imageUrl = userCharacter.getCharacter().getImageUrl(); + this.purchasedAt = userCharacter.getCreatedAt(); + this.isEquipped = isEquipped; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetStoreCharacterRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetStoreCharacterRes.java new file mode 100644 index 00000000..6396151f --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetStoreCharacterRes.java @@ -0,0 +1,46 @@ +package devkor.com.teamcback.domain.character.dto.response; + +import devkor.com.teamcback.domain.character.entity.KoCharacter; +import devkor.com.teamcback.domain.character.entity.PurchaseStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import lombok.Getter; + +@Schema(description = "스토어 캐릭터 정보") +@Getter +public class GetStoreCharacterRes { + @Schema(description = "캐릭터 ID", example = "1") + private Long characterId; + @Schema(description = "캐릭터 이름", example = "아기 호랑이") + private String name; + @Schema(description = "캐릭터 설명", example = "10 포인트로 구매할 수 있는 캐릭터") + private String description; + @Schema(description = "캐릭터 대사", example = "같이 캠퍼스를 누벼볼까?") + private String quote; + @Schema(description = "캐릭터 이미지 URL") + private String imageUrl; + @Schema(description = "구매 가격 (포인트)", example = "10") + private Integer price; + @Schema(description = "해금 레벨", example = "2") + private Integer requiredLevel; + @Schema(description = "구매 상태 (OWNED/PURCHASABLE/LOCKED/NOT_ENOUGH_POINT)", example = "PURCHASABLE") + private PurchaseStatus status; + @Schema(description = "구매 일시 (미보유 시 null)") + private LocalDateTime purchasedAt; + @Schema(description = "대표 장착 여부", example = "false") + private boolean isEquipped; + + public GetStoreCharacterRes(KoCharacter character, PurchaseStatus status, + LocalDateTime purchasedAt, boolean isEquipped) { + this.characterId = character.getCharacterId(); + this.name = character.getName(); + this.description = character.getDescription(); + this.quote = character.getQuote(); + this.imageUrl = character.getImageUrl(); + this.price = character.getPrice(); + this.requiredLevel = character.getRequiredLevel(); + this.status = status; + this.purchasedAt = purchasedAt; + this.isEquipped = isEquipped; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetStoreRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetStoreRes.java new file mode 100644 index 00000000..8501940e --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GetStoreRes.java @@ -0,0 +1,19 @@ +package devkor.com.teamcback.domain.character.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import lombok.Getter; + +@Schema(description = "스토어 정보 (보유 포인트 + 캐릭터 목록)") +@Getter +public class GetStoreRes { + @Schema(description = "보유 포인트", example = "25") + private Long point; + @Schema(description = "캐릭터 목록") + private List characterList; + + public GetStoreRes(Long point, List characterList) { + this.point = point; + this.characterList = characterList; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/GrantCharacterRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GrantCharacterRes.java new file mode 100644 index 00000000..3f196fce --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/dto/response/GrantCharacterRes.java @@ -0,0 +1,15 @@ +package devkor.com.teamcback.domain.character.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +@Schema(description = "캐릭터 수동 지급 결과") +@Getter +public class GrantCharacterRes { + @Schema(description = "지급된 사용자 캐릭터 ID", example = "1") + private Long userCharacterId; + + public GrantCharacterRes(Long userCharacterId) { + this.userCharacterId = userCharacterId; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/ModifyCharacterRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/ModifyCharacterRes.java new file mode 100644 index 00000000..560da964 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/dto/response/ModifyCharacterRes.java @@ -0,0 +1,7 @@ +package devkor.com.teamcback.domain.character.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "캐릭터 수정 결과") +public class ModifyCharacterRes { +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/PurchaseCharacterRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/PurchaseCharacterRes.java new file mode 100644 index 00000000..287453b8 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/dto/response/PurchaseCharacterRes.java @@ -0,0 +1,35 @@ +package devkor.com.teamcback.domain.character.dto.response; + +import devkor.com.teamcback.domain.character.entity.UserCharacter; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import lombok.Getter; + +@Schema(description = "캐릭터 구매 결과") +@Getter +public class PurchaseCharacterRes { + @Schema(description = "캐릭터 ID", example = "1") + private Long characterId; + @Schema(description = "캐릭터 이름", example = "아기 호랑이") + private String name; + @Schema(description = "캐릭터 대사", example = "같이 캠퍼스를 누벼볼까?") + private String quote; + @Schema(description = "캐릭터 이미지 URL") + private String imageUrl; + @Schema(description = "지불한 포인트", example = "10") + private Integer price; + @Schema(description = "구매 후 잔여 포인트", example = "15") + private Long remainingPoint; + @Schema(description = "구매 일시") + private LocalDateTime purchasedAt; + + public PurchaseCharacterRes(UserCharacter userCharacter, Long remainingPoint) { + this.characterId = userCharacter.getCharacter().getCharacterId(); + this.name = userCharacter.getCharacter().getName(); + this.quote = userCharacter.getCharacter().getQuote(); + this.imageUrl = userCharacter.getCharacter().getImageUrl(); + this.price = userCharacter.getCharacter().getPrice(); + this.remainingPoint = remainingPoint; + this.purchasedAt = userCharacter.getCreatedAt(); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/dto/response/UnequipCharacterRes.java b/src/main/java/devkor/com/teamcback/domain/character/dto/response/UnequipCharacterRes.java new file mode 100644 index 00000000..429bc45b --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/dto/response/UnequipCharacterRes.java @@ -0,0 +1,7 @@ +package devkor.com.teamcback.domain.character.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "대표 캐릭터 장착 해제 결과") +public class UnequipCharacterRes { +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/entity/KoCharacter.java b/src/main/java/devkor/com/teamcback/domain/character/entity/KoCharacter.java new file mode 100644 index 00000000..e89a95f7 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/entity/KoCharacter.java @@ -0,0 +1,79 @@ +package devkor.com.teamcback.domain.character.entity; + +import devkor.com.teamcback.domain.character.dto.request.CreateCharacterReq; +import devkor.com.teamcback.domain.character.dto.request.ModifyCharacterReq; +import devkor.com.teamcback.domain.common.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.ColumnDefault; + +// java.lang.Character와의 충돌을 피하기 위해 KoCharacter로 명명 (koyeon의 Ko- 접두어 선례) +@Entity +@Getter +@Table(name = "tb_character") +@NoArgsConstructor +public class KoCharacter extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long characterId; + + @Column(nullable = false, unique = true) + private String name; + + @Column(length = 500) + private String description; + + @Column(length = 200) + private String quote; // 캐릭터 클릭 시 보여줄 대사 + + @Column(nullable = false) + private String imageUrl; + + @Column(nullable = false) + private Integer price; // 구매 가격 (포인트) + + // 해금 레벨: 사용자 레벨이 이 값 이상이어야 구매 가능 (1이면 제한 없음) + @ColumnDefault("1") + @Column(nullable = false) + private Integer requiredLevel = 1; + + @Column(nullable = false) + private Integer displayOrder = 0; + + @Column(nullable = false) + private boolean isActive = true; + + public KoCharacter(String name, String description, String quote, String imageUrl, + Integer price, Integer requiredLevel, Integer displayOrder, boolean isActive) { + this.name = name; + this.description = description; + this.quote = quote; + this.imageUrl = imageUrl; + this.price = price; + this.requiredLevel = requiredLevel; + this.displayOrder = displayOrder; + this.isActive = isActive; + } + + public KoCharacter(CreateCharacterReq req, String imageUrl) { + this(req.getName(), req.getDescription(), req.getQuote(), imageUrl, req.getPrice(), + req.getRequiredLevel(), req.getDisplayOrder(), req.isActive()); + } + + public void update(ModifyCharacterReq req, String imageUrl) { + this.name = req.getName(); + this.description = req.getDescription(); + this.quote = req.getQuote(); + this.imageUrl = imageUrl; + this.price = req.getPrice(); + this.requiredLevel = req.getRequiredLevel(); + this.displayOrder = req.getDisplayOrder(); + this.isActive = req.isActive(); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/entity/PurchaseStatus.java b/src/main/java/devkor/com/teamcback/domain/character/entity/PurchaseStatus.java new file mode 100644 index 00000000..8e2d7bd1 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/entity/PurchaseStatus.java @@ -0,0 +1,15 @@ +package devkor.com.teamcback.domain.character.entity; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum PurchaseStatus { + OWNED("보유"), + PURCHASABLE("구매 가능"), + LOCKED("미해금"), // 해금 레벨 미달 (포인트와 무관하게 구매 불가) + NOT_ENOUGH_POINT("포인트 부족"); + + private final String name; +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/entity/UserCharacter.java b/src/main/java/devkor/com/teamcback/domain/character/entity/UserCharacter.java new file mode 100644 index 00000000..47f153b2 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/entity/UserCharacter.java @@ -0,0 +1,40 @@ +package devkor.com.teamcback.domain.character.entity; + +import devkor.com.teamcback.domain.common.entity.BaseEntity; +import devkor.com.teamcback.domain.user.entity.User; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Getter; +import lombok.NoArgsConstructor; + +// createdAt(BaseEntity)이 획득일. UNIQUE(user_id, character_id)가 동시 중복 해금의 최종 방어선 +@Entity +@Getter +@Table(name = "tb_user_character", + uniqueConstraints = @UniqueConstraint(name = "uk_user_character", columnNames = {"user_id", "character_id"})) +@NoArgsConstructor +public class UserCharacter extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long userCharacterId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "character_id", nullable = false) + private KoCharacter character; + + public UserCharacter(User user, KoCharacter character) { + this.user = user; + this.character = character; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/event/CharacterUnlockedEvent.java b/src/main/java/devkor/com/teamcback/domain/character/event/CharacterUnlockedEvent.java new file mode 100644 index 00000000..0c6f0c6c --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/event/CharacterUnlockedEvent.java @@ -0,0 +1,9 @@ +package devkor.com.teamcback.domain.character.event; + +public record CharacterUnlockedEvent( + Long userId, + Long characterId, + Long userCharacterId, + String characterName +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/repository/CharacterRepository.java b/src/main/java/devkor/com/teamcback/domain/character/repository/CharacterRepository.java new file mode 100644 index 00000000..6d206087 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/repository/CharacterRepository.java @@ -0,0 +1,16 @@ +package devkor.com.teamcback.domain.character.repository; + +import devkor.com.teamcback.domain.character.entity.KoCharacter; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CharacterRepository extends JpaRepository { + List findAllByIsActiveTrueOrderByDisplayOrderAsc(); + + List findAllByOrderByDisplayOrderAsc(); + + boolean existsByName(String name); + + Optional findByName(String name); +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/repository/UserCharacterRepository.java b/src/main/java/devkor/com/teamcback/domain/character/repository/UserCharacterRepository.java new file mode 100644 index 00000000..13045b54 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/repository/UserCharacterRepository.java @@ -0,0 +1,19 @@ +package devkor.com.teamcback.domain.character.repository; + +import devkor.com.teamcback.domain.character.entity.KoCharacter; +import devkor.com.teamcback.domain.character.entity.UserCharacter; +import devkor.com.teamcback.domain.user.entity.User; +import java.util.List; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserCharacterRepository extends JpaRepository { + @EntityGraph(attributePaths = "character") + List findAllByUser(User user); + + boolean existsByUserAndCharacter(User user, KoCharacter character); + + boolean existsByCharacter(KoCharacter character); + + void deleteAllByUser(User user); +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/service/AdminStoreService.java b/src/main/java/devkor/com/teamcback/domain/character/service/AdminStoreService.java new file mode 100644 index 00000000..c7fb35f0 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/service/AdminStoreService.java @@ -0,0 +1,155 @@ +package devkor.com.teamcback.domain.character.service; + +import static devkor.com.teamcback.global.response.ResultCode.ALREADY_OWNED_CHARACTER; +import static devkor.com.teamcback.global.response.ResultCode.CHARACTER_IN_USE; +import static devkor.com.teamcback.global.response.ResultCode.INVALID_INPUT; +import static devkor.com.teamcback.global.response.ResultCode.NOT_FOUND_CHARACTER; +import static devkor.com.teamcback.global.response.ResultCode.NOT_FOUND_USER; + +import devkor.com.teamcback.domain.character.dto.request.CreateCharacterReq; +import devkor.com.teamcback.domain.character.dto.request.ModifyCharacterReq; +import devkor.com.teamcback.domain.character.dto.response.CreateCharacterRes; +import devkor.com.teamcback.domain.character.dto.response.DeleteCharacterRes; +import devkor.com.teamcback.domain.character.dto.response.GetAdminCharacterListRes; +import devkor.com.teamcback.domain.character.dto.response.GetAdminCharacterRes; +import devkor.com.teamcback.domain.character.dto.response.GrantCharacterRes; +import devkor.com.teamcback.domain.character.dto.response.ModifyCharacterRes; +import devkor.com.teamcback.domain.character.entity.KoCharacter; +import devkor.com.teamcback.domain.character.entity.UserCharacter; +import devkor.com.teamcback.domain.character.event.CharacterUnlockedEvent; +import devkor.com.teamcback.domain.character.repository.CharacterRepository; +import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; +import devkor.com.teamcback.domain.user.entity.Level; +import devkor.com.teamcback.domain.user.entity.User; +import devkor.com.teamcback.domain.user.repository.UserRepository; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import devkor.com.teamcback.infra.s3.FilePath; +import devkor.com.teamcback.infra.s3.S3Util; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +@Service +@RequiredArgsConstructor +public class AdminStoreService { + private final CharacterRepository characterRepository; + private final UserCharacterRepository userCharacterRepository; + private final UserRepository userRepository; + private final S3Util s3Util; + private final ApplicationEventPublisher eventPublisher; + + /** + * 캐릭터 목록 조회 (비활성 포함) + */ + @Transactional(readOnly = true) + public GetAdminCharacterListRes getCharacterList() { + List characterList = characterRepository.findAllByOrderByDisplayOrderAsc().stream() + .map(GetAdminCharacterRes::new) + .toList(); + + return new GetAdminCharacterListRes(characterList); + } + + /** + * 캐릭터 생성 + */ + @Transactional + public CreateCharacterRes createCharacter(CreateCharacterReq req) { + validatePrice(req.getPrice()); + validateRequiredLevel(req.getRequiredLevel()); + + String imageUrl = uploadImage(req.getImage()); + if(imageUrl == null) throw new GlobalException(INVALID_INPUT); // 이미지 필수 + + KoCharacter character = characterRepository.save(new KoCharacter(req, imageUrl)); + + return new CreateCharacterRes(character.getCharacterId()); + } + + /** + * 캐릭터 수정 (이미지 미첨부 시 기존 이미지 유지) + */ + @Transactional + public ModifyCharacterRes modifyCharacter(Long characterId, ModifyCharacterReq req) { + validatePrice(req.getPrice()); + validateRequiredLevel(req.getRequiredLevel()); + + KoCharacter character = findCharacter(characterId); + + String imageUrl = uploadImage(req.getImage()); + if(imageUrl == null) { + imageUrl = character.getImageUrl(); + } else if(character.getImageUrl() != null) { + s3Util.deleteFile(character.getImageUrl(), FilePath.CHARACTER); + } + + character.update(req, imageUrl); + + return new ModifyCharacterRes(); + } + + /** + * 캐릭터 삭제 (구매한 사용자가 있으면 불가 - isActive=false로 숨김 처리 안내) + */ + @Transactional + public DeleteCharacterRes deleteCharacter(Long characterId) { + KoCharacter character = findCharacter(characterId); + + if(userCharacterRepository.existsByCharacter(character)) { + throw new GlobalException(CHARACTER_IN_USE); + } + + if(character.getImageUrl() != null) { + s3Util.deleteFile(character.getImageUrl(), FilePath.CHARACTER); + } + characterRepository.delete(character); + + return new DeleteCharacterRes(); + } + + /** + * 캐릭터 수동 지급 (이벤트 보상 등, 포인트 차감 없음) + */ + @Transactional + public GrantCharacterRes grantCharacter(Long characterId, Long userId) { + KoCharacter character = findCharacter(characterId); + User user = userRepository.findById(userId).orElseThrow(() -> new GlobalException(NOT_FOUND_USER)); + + if(userCharacterRepository.existsByUserAndCharacter(user, character)) { + throw new GlobalException(ALREADY_OWNED_CHARACTER); + } + + UserCharacter userCharacter = userCharacterRepository.saveAndFlush(new UserCharacter(user, character)); + eventPublisher.publishEvent(new CharacterUnlockedEvent( + user.getUserId(), + character.getCharacterId(), + userCharacter.getUserCharacterId(), + character.getName() + )); + + return new GrantCharacterRes(userCharacter.getUserCharacterId()); + } + + private void validatePrice(Integer price) { + if(price == null || price < 0) throw new GlobalException(INVALID_INPUT); + } + + private void validateRequiredLevel(Integer requiredLevel) { + int maxLevel = Level.values()[Level.values().length - 1].getLevelNumber(); + if(requiredLevel == null || requiredLevel < 1 || requiredLevel > maxLevel) { + throw new GlobalException(INVALID_INPUT); + } + } + + private String uploadImage(MultipartFile image) { + if(image == null || image.isEmpty()) return null; + return s3Util.uploadFile(image, FilePath.CHARACTER); + } + + private KoCharacter findCharacter(Long characterId) { + return characterRepository.findById(characterId).orElseThrow(() -> new GlobalException(NOT_FOUND_CHARACTER)); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/service/CharacterDataSeeder.java b/src/main/java/devkor/com/teamcback/domain/character/service/CharacterDataSeeder.java new file mode 100644 index 00000000..eb28f367 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/service/CharacterDataSeeder.java @@ -0,0 +1,96 @@ +package devkor.com.teamcback.domain.character.service; + +import devkor.com.teamcback.domain.character.entity.KoCharacter; +import devkor.com.teamcback.domain.character.repository.CharacterRepository; +import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Component; + +/** + * 캐릭터 확정 데이터 시더 (기획 문서 "포인트 상점 기능 > 캐릭터 DB 확정" 기준, 2026-07-29). + * 이름 존재 여부로 행 단위 멱등 처리, 멀티 인스턴스 동시 부팅은 name UNIQUE 제약이 방어. + * 기획 확정 전에 시드했던 구 플레이스홀더 5종은 보유자가 없을 때만 정리한다. + */ +@Slf4j +@Component +@Profile("!test") +@RequiredArgsConstructor +public class CharacterDataSeeder implements ApplicationRunner { + private final CharacterRepository characterRepository; + private final UserCharacterRepository userCharacterRepository; + + // 기획 확정 전 플레이스홀더 시드 이름 (확정 DB와 이름이 달라 공존하게 되므로 제거 대상) + private static final List LEGACY_SEED_NAMES = + List.of("아기 호랑이", "학생 호랑이", "청년 호랑이", "석사 호랑이", "호랑이 대장"); + + @Value("${character.image.base-url}") + private String imageBaseUrl; + + // 메서드 전체를 @Transactional로 묶으면 중복 키 예외를 잡아도 트랜잭션이 rollback-only로 오염되어 + // 커밋 시 UnexpectedRollbackException으로 부팅이 실패한다. save()별 개별 트랜잭션으로 둔다. + @Override + public void run(ApplicationArguments args) { + removeLegacySeeds(); + + List seeds = List.of( + new KoCharacter("애기호랑이", "기본 아바타", + "나 호랑이 맞아요?", imageUrl("01_aegi"), 0, 1, 1, true), + new KoCharacter("꼬마호랑이", "레벨 2 달성 시 해금", + "엄마가 발이 크면 키 크는 거래요. 저 발 엄청 커요!", imageUrl("02_kkoma"), 15, 2, 2, true), + new KoCharacter("포동호랑이", "레벨 3 달성 시 해금", + "어디든 가고 싶어요! 이따 어디 갈까요? 미래관? 과도?", imageUrl("03_podong"), 20, 3, 3, true), + new KoCharacter("학생호랑이", "레벨 4 달성 시 해금", + "안경 쓰면 똑똑해보이잖아요. 머릿속에 들어오는 건 없어요.", imageUrl("04_haksaeng"), 25, 4, 4, true), + new KoCharacter("어른호랑이", "레벨 5 달성 시 해금", + "뭐든 다 할 수 있을 것 같아요. 제가 못할 리 없죠!", imageUrl("05_eoreun"), 30, 5, 5, true), + new KoCharacter("피곤 호랑이", "포인트로 바로 구매 가능", + "대체 며칠째 밤샘인지.. 근데 오늘이 무슨 요일이죠?", imageUrl("06_pigon"), 35, 1, 6, true), + new KoCharacter("과잠 호랑이", "포인트로 바로 구매 가능", + "대학생, 원래 이렇게 힘든 거였어요? 살려주세요…", imageUrl("07_gwajam"), 45, 1, 7, true), + new KoCharacter("로봇호랑이", "포인트로 바로 구매 가능", + "다치기 싫어서 강철 슈트 입었어요. 강해보이죠?", imageUrl("08_robot"), 50, 1, 8, true), + new KoCharacter("천사 호랑이", "포인트로 바로 구매 가능", + "하루에 한번은 좋은 일이 생길 거예요!", imageUrl("09_cheonsa"), 80, 1, 9, true), + new KoCharacter("악마 호랑이", "포인트로 바로 구매 가능", + "화가 난 것 같지만 사실 부끄러움을 숨기고 있는 거에요…", imageUrl("10_akma"), 80, 1, 10, true) + ); + + int saved = 0; + for (KoCharacter seed : seeds) { + if(characterRepository.existsByName(seed.getName())) continue; + try { + characterRepository.save(seed); + saved++; + } catch (DataIntegrityViolationException e) { // 다른 인스턴스가 먼저 적재한 경우 + log.info("캐릭터 시드 중복 감지, 건너뜀: {}", seed.getName()); + } + } + if(saved > 0) { + log.info("캐릭터 시드 데이터 {}건 적재 완료", saved); + } + } + + private void removeLegacySeeds() { + for (String legacyName : LEGACY_SEED_NAMES) { + characterRepository.findByName(legacyName).ifPresent(legacy -> { + if(userCharacterRepository.existsByCharacter(legacy)) { // 보유자가 있으면 수동 정리 필요 + log.warn("구 플레이스홀더 캐릭터에 보유자가 있어 삭제하지 않음: {}", legacyName); + return; + } + characterRepository.delete(legacy); + log.info("구 플레이스홀더 캐릭터 삭제: {}", legacyName); + }); + } + } + + private String imageUrl(String fileName) { + return imageBaseUrl + "/" + fileName + ".png"; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/character/service/StoreService.java b/src/main/java/devkor/com/teamcback/domain/character/service/StoreService.java new file mode 100644 index 00000000..5ee5fd66 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/character/service/StoreService.java @@ -0,0 +1,212 @@ +package devkor.com.teamcback.domain.character.service; + +import static devkor.com.teamcback.global.response.ResultCode.ALREADY_OWNED_CHARACTER; +import static devkor.com.teamcback.global.response.ResultCode.INACTIVE_CHARACTER; +import static devkor.com.teamcback.global.response.ResultCode.INSUFFICIENT_LEVEL; +import static devkor.com.teamcback.global.response.ResultCode.INSUFFICIENT_POINT; +import static devkor.com.teamcback.global.response.ResultCode.NOT_FOUND_CHARACTER; +import static devkor.com.teamcback.global.response.ResultCode.NOT_FOUND_USER; +import static devkor.com.teamcback.global.response.ResultCode.NOT_OWNED_CHARACTER; + +import devkor.com.teamcback.domain.character.dto.response.EquipCharacterRes; +import devkor.com.teamcback.domain.character.dto.response.GetMyCharacterListRes; +import devkor.com.teamcback.domain.character.dto.response.GetMyCharacterRes; +import devkor.com.teamcback.domain.character.dto.response.GetStoreCharacterRes; +import devkor.com.teamcback.domain.character.dto.response.GetStoreRes; +import devkor.com.teamcback.domain.character.dto.response.PurchaseCharacterRes; +import devkor.com.teamcback.domain.character.dto.response.UnequipCharacterRes; +import devkor.com.teamcback.domain.character.entity.KoCharacter; +import devkor.com.teamcback.domain.character.entity.PurchaseStatus; +import devkor.com.teamcback.domain.character.entity.UserCharacter; +import devkor.com.teamcback.domain.character.event.CharacterUnlockedEvent; +import devkor.com.teamcback.domain.character.repository.CharacterRepository; +import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; +import devkor.com.teamcback.domain.user.entity.Level; +import devkor.com.teamcback.domain.user.entity.User; +import devkor.com.teamcback.domain.user.repository.UserRepository; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class StoreService { + private final CharacterRepository characterRepository; + private final UserCharacterRepository userCharacterRepository; + private final UserRepository userRepository; + private final ApplicationEventPublisher eventPublisher; + + /** + * 스토어 조회 (보유 포인트 + 캐릭터 목록) + * 정렬: 1순위 보유(해금 레벨순→가격순) → 2순위 구매 가능(가격 낮은순) + * → 3순위 미해금·레벨 미달(해금 레벨순) → 4순위 포인트 부족(가격 낮은순) + */ + @Transactional(readOnly = true) + public GetStoreRes getStore(Long userId) { + User user = findUser(userId); + List characters = characterRepository.findAllByIsActiveTrueOrderByDisplayOrderAsc(); + + Map ownedMap = userCharacterRepository.findAllByUser(user).stream() + .collect(Collectors.toMap(uc -> uc.getCharacter().getCharacterId(), Function.identity())); + + long point = user.getPoint(); + int userLevel = levelNumberOf(user); + Map statusMap = characters.stream() + .collect(Collectors.toMap(KoCharacter::getCharacterId, + c -> getPurchaseStatus(c, ownedMap.containsKey(c.getCharacterId()), userLevel, point))); + + List characterList = characters.stream() + .sorted(storeOrder(statusMap)) + .map(character -> { + UserCharacter owned = ownedMap.get(character.getCharacterId()); + boolean isEquipped = character.getCharacterId().equals(user.getEquippedCharacterId()); + return new GetStoreCharacterRes(character, statusMap.get(character.getCharacterId()), + owned == null ? null : owned.getCreatedAt(), isEquipped); + }) + .toList(); + + return new GetStoreRes(user.getPoint(), characterList); + } + + /** + * 내 보유 캐릭터 목록 조회 + */ + @Transactional(readOnly = true) + public GetMyCharacterListRes getMyCharacters(Long userId) { + User user = findUser(userId); + + List characterList = userCharacterRepository.findAllByUser(user).stream() + .map(uc -> new GetMyCharacterRes(uc, + uc.getCharacter().getCharacterId().equals(user.getEquippedCharacterId()))) + .toList(); + + return new GetMyCharacterListRes(user.getPoint(), user.getEquippedCharacterId(), characterList); + } + + /** + * 캐릭터 구매 (포인트 차감) + */ + @Transactional + public PurchaseCharacterRes purchaseCharacter(Long userId, Long characterId) { + User user = findUser(userId); + KoCharacter character = findCharacter(characterId); + + if(!character.isActive()) throw new GlobalException(INACTIVE_CHARACTER); + + if(userCharacterRepository.existsByUserAndCharacter(user, character)) { + throw new GlobalException(ALREADY_OWNED_CHARACTER); + } + + // 해금 레벨 미달이면 포인트와 무관하게 구매 불가 + if(levelNumberOf(user) < character.getRequiredLevel()) { + throw new GlobalException(INSUFFICIENT_LEVEL); + } + + // 잔액 검증과 차감을 단일 조건부 UPDATE로 수행 (동시 구매 시 이중 차감 방지) + if(userRepository.deductPoint(userId, character.getPrice()) == 0) { + throw new GlobalException(INSUFFICIENT_POINT); + } + + // deductPoint가 영속성 컨텍스트를 비우므로 차감이 반영된 상태로 재조회 + user = findUser(userId); + character = findCharacter(characterId); + + try { + UserCharacter userCharacter = userCharacterRepository.saveAndFlush(new UserCharacter(user, character)); + eventPublisher.publishEvent(new CharacterUnlockedEvent( + user.getUserId(), + character.getCharacterId(), + userCharacter.getUserCharacterId(), + character.getName() + )); + return new PurchaseCharacterRes(userCharacter, user.getPoint()); + } catch (DataIntegrityViolationException e) { // 동시 중복 구매는 UNIQUE 제약으로 차단 (롤백으로 차감 복구) + throw new GlobalException(ALREADY_OWNED_CHARACTER); + } + } + + /** + * 대표 캐릭터 장착 + */ + @Transactional + public EquipCharacterRes equipCharacter(Long userId, Long characterId) { + User user = findUser(userId); + KoCharacter character = findCharacter(characterId); + + if(!userCharacterRepository.existsByUserAndCharacter(user, character)) { + throw new GlobalException(NOT_OWNED_CHARACTER); + } + + user.updateEquippedCharacter(character.getCharacterId()); + + return new EquipCharacterRes(character.getCharacterId()); + } + + /** + * 대표 캐릭터 장착 해제 + */ + @Transactional + public UnequipCharacterRes unequipCharacter(Long userId) { + User user = findUser(userId); + user.updateEquippedCharacter(null); + + return new UnequipCharacterRes(); + } + + private PurchaseStatus getPurchaseStatus(KoCharacter character, boolean owned, int userLevel, long point) { + if(owned) return PurchaseStatus.OWNED; + if(userLevel < character.getRequiredLevel()) return PurchaseStatus.LOCKED; + return point >= character.getPrice() ? PurchaseStatus.PURCHASABLE : PurchaseStatus.NOT_ENOUGH_POINT; + } + + private Comparator storeOrder(Map statusMap) { + return Comparator + .comparingInt((KoCharacter c) -> statusTier(statusMap.get(c.getCharacterId()))) + .thenComparingInt(c -> primarySortKey(statusMap.get(c.getCharacterId()), c)) + .thenComparingInt(c -> secondarySortKey(statusMap.get(c.getCharacterId()), c)) + .thenComparing(KoCharacter::getDisplayOrder) + .thenComparing(KoCharacter::getCharacterId); + } + + private int statusTier(PurchaseStatus status) { + return switch (status) { + case OWNED -> 0; + case PURCHASABLE -> 1; + case LOCKED -> 2; + case NOT_ENOUGH_POINT -> 3; + }; + } + + private int primarySortKey(PurchaseStatus status, KoCharacter character) { + return switch (status) { + case OWNED, LOCKED -> character.getRequiredLevel(); // 레벨순 + case PURCHASABLE, NOT_ENOUGH_POINT -> character.getPrice(); // 포인트 낮은순 + }; + } + + private int secondarySortKey(PurchaseStatus status, KoCharacter character) { + return status == PurchaseStatus.OWNED ? character.getPrice() : 0; // 보유는 레벨순 다음 포인트순 + } + + private int levelNumberOf(User user) { + // 백필 전 레거시 행 방어: level 컬럼이 비어 있으면 score로 계산 + Level level = user.getLevel() != null ? user.getLevel() : Level.fromScore(user.getScore()); + return level.getLevelNumber(); + } + + private User findUser(Long userId) { + return userRepository.findById(userId).orElseThrow(() -> new GlobalException(NOT_FOUND_USER)); + } + + private KoCharacter findCharacter(Long characterId) { + return characterRepository.findById(characterId).orElseThrow(() -> new GlobalException(NOT_FOUND_CHARACTER)); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/client/ExpoPushApi.java b/src/main/java/devkor/com/teamcback/domain/notification/client/ExpoPushApi.java new file mode 100644 index 00000000..9c67ed8b --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/client/ExpoPushApi.java @@ -0,0 +1,25 @@ +package devkor.com.teamcback.domain.notification.client; + +import devkor.com.teamcback.domain.notification.config.ExpoPushFeignConfig; +import devkor.com.teamcback.domain.notification.dto.expo.ExpoPushRequest; +import devkor.com.teamcback.domain.notification.dto.expo.ExpoPushResponse; +import devkor.com.teamcback.domain.notification.dto.expo.ExpoReceiptRequest; +import devkor.com.teamcback.domain.notification.dto.expo.ExpoReceiptResponse; +import java.util.List; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +@FeignClient( + name = "expoPushApi", + url = "${push.expo.base-url}", + configuration = ExpoPushFeignConfig.class +) +public interface ExpoPushApi { + + @PostMapping("/send") + ExpoPushResponse send(@RequestBody List requests); + + @PostMapping("/getReceipts") + ExpoReceiptResponse getReceipts(@RequestBody ExpoReceiptRequest request); +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/client/ExpoPushClient.java b/src/main/java/devkor/com/teamcback/domain/notification/client/ExpoPushClient.java new file mode 100644 index 00000000..1fc0ba38 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/client/ExpoPushClient.java @@ -0,0 +1,165 @@ +package devkor.com.teamcback.domain.notification.client; + +import devkor.com.teamcback.domain.notification.dto.expo.ExpoPushRequest; +import devkor.com.teamcback.domain.notification.dto.expo.ExpoPushResponse; +import devkor.com.teamcback.domain.notification.dto.expo.ExpoReceiptRequest; +import devkor.com.teamcback.domain.notification.dto.expo.ExpoReceiptResponse; +import feign.FeignException; +import feign.RetryableException; +import feign.codec.DecodeException; +import feign.codec.EncodeException; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class ExpoPushClient { + + private static final int MAX_SEND_REQUEST_COUNT = 100; + private static final int MAX_RECEIPT_ID_COUNT = 1000; + + private final ExpoPushApi expoPushApi; + + public ExpoPushResponse send(List requests) { + validateSendRequests(requests); + + try { + ExpoPushResponse response = expoPushApi.send(requests); + validateSendResponse(response, requests.size()); + return response; + } catch (ExpoPushClientException e) { + throw e; + } catch (RetryableException e) { + throw requestFailed(null, true, e); + } catch (DecodeException e) { + throw parsingFailed(e); + } catch (EncodeException e) { + throw invalidInput(e); + } catch (FeignException e) { + throw requestFailed(e.status(), isRetryable(e.status()), e); + } + } + + public ExpoReceiptResponse getReceipts(List ticketIds) { + validateReceiptIds(ticketIds); + + try { + ExpoReceiptResponse response = expoPushApi.getReceipts(new ExpoReceiptRequest(ticketIds)); + if (response == null) { + throw parsingFailed(); + } + return response; + } catch (ExpoPushClientException e) { + throw e; + } catch (RetryableException e) { + throw requestFailed(null, true, e); + } catch (DecodeException e) { + throw parsingFailed(e); + } catch (EncodeException e) { + throw invalidInput(e); + } catch (FeignException e) { + throw requestFailed(e.status(), isRetryable(e.status()), e); + } + } + + private void validateSendResponse( + ExpoPushResponse response, + int requestCount + ) { + if (response == null || response.data() == null || response.data().size() != requestCount) { + throw parsingFailed(); + } + } + + private void validateSendRequests(List requests) { + if (requests == null || requests.isEmpty()) { + throw invalidInput(); + } + + if (requests.size() > MAX_SEND_REQUEST_COUNT) { + throw invalidInput(); + } + + boolean hasInvalidRequest = requests.stream() + .anyMatch(request -> request == null || !hasText(request.to())); + + if (hasInvalidRequest) { + throw invalidInput(); + } + } + + private void validateReceiptIds(List ticketIds) { + if (ticketIds == null || ticketIds.isEmpty()) { + throw invalidInput(); + } + + if (ticketIds.size() > MAX_RECEIPT_ID_COUNT) { + throw invalidInput(); + } + + boolean hasBlankTicketId = ticketIds.stream() + .anyMatch(ticketId -> !hasText(ticketId)); + + if (hasBlankTicketId) { + throw invalidInput(); + } + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } + + private boolean isRetryable(int status) { + return status == HttpStatus.TOO_MANY_REQUESTS.value() + || (status >= 500 && status < 600); + } + + private ExpoPushClientException invalidInput() { + return new ExpoPushClientException( + "Invalid Expo push client request", + HttpStatus.BAD_REQUEST.value(), + false + ); + } + + private ExpoPushClientException invalidInput(Throwable cause) { + return new ExpoPushClientException( + "Invalid Expo push client request", + HttpStatus.BAD_REQUEST.value(), + false, + cause + ); + } + + private ExpoPushClientException parsingFailed() { + return new ExpoPushClientException( + "Failed to parse Expo push response", + null, + false + ); + } + + private ExpoPushClientException parsingFailed(Throwable cause) { + return new ExpoPushClientException( + "Failed to parse Expo push response", + null, + false, + cause + ); + } + + private ExpoPushClientException requestFailed( + Integer httpStatus, + boolean retryable, + Throwable cause + ) { + return new ExpoPushClientException( + "Expo push request failed", + httpStatus, + retryable, + cause + ); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/client/ExpoPushClientException.java b/src/main/java/devkor/com/teamcback/domain/notification/client/ExpoPushClientException.java new file mode 100644 index 00000000..09856dc0 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/client/ExpoPushClientException.java @@ -0,0 +1,31 @@ +package devkor.com.teamcback.domain.notification.client; + +import lombok.Getter; + +@Getter +public class ExpoPushClientException extends RuntimeException { + + private final Integer httpStatus; + private final boolean retryable; + + public ExpoPushClientException( + String message, + Integer httpStatus, + boolean retryable + ) { + super(message); + this.httpStatus = httpStatus; + this.retryable = retryable; + } + + public ExpoPushClientException( + String message, + Integer httpStatus, + boolean retryable, + Throwable cause + ) { + super(message, cause); + this.httpStatus = httpStatus; + this.retryable = retryable; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/config/ExpoPushFeignConfig.java b/src/main/java/devkor/com/teamcback/domain/notification/config/ExpoPushFeignConfig.java new file mode 100644 index 00000000..267b1f85 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/config/ExpoPushFeignConfig.java @@ -0,0 +1,37 @@ +package devkor.com.teamcback.domain.notification.config; + +import feign.Request; +import feign.RequestInterceptor; +import feign.Retryer; +import java.util.concurrent.TimeUnit; +import org.springframework.context.annotation.Bean; +import org.springframework.http.HttpHeaders; +import org.springframework.util.StringUtils; + +public class ExpoPushFeignConfig { + + @Bean + public Request.Options expoPushRequestOptions(ExpoPushProperties properties) { + return new Request.Options( + properties.connectTimeout().toMillis(), + TimeUnit.MILLISECONDS, + properties.readTimeout().toMillis(), + TimeUnit.MILLISECONDS, + true + ); + } + + @Bean + public RequestInterceptor expoPushAuthorizationInterceptor(ExpoPushProperties properties) { + return template -> { + if (StringUtils.hasText(properties.accessToken())) { + template.header(HttpHeaders.AUTHORIZATION, "Bearer " + properties.accessToken()); + } + }; + } + + @Bean + public Retryer expoPushRetryer() { + return Retryer.NEVER_RETRY; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/config/ExpoPushProperties.java b/src/main/java/devkor/com/teamcback/domain/notification/config/ExpoPushProperties.java new file mode 100644 index 00000000..089a0248 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/config/ExpoPushProperties.java @@ -0,0 +1,28 @@ +package devkor.com.teamcback.domain.notification.config; + +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "push.expo") +public record ExpoPushProperties( + String baseUrl, + String accessToken, + Duration connectTimeout, + Duration readTimeout +) { + + public ExpoPushProperties { + if (baseUrl == null || baseUrl.isBlank()) { + baseUrl = "https://exp.host/--/api/v2/push"; + } + if (accessToken == null) { + accessToken = ""; + } + if (connectTimeout == null) { + connectTimeout = Duration.ofSeconds(3); + } + if (readTimeout == null) { + readTimeout = Duration.ofSeconds(10); + } + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/config/ExpoPushPropertiesConfig.java b/src/main/java/devkor/com/teamcback/domain/notification/config/ExpoPushPropertiesConfig.java new file mode 100644 index 00000000..5bef5bd8 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/config/ExpoPushPropertiesConfig.java @@ -0,0 +1,14 @@ +package devkor.com.teamcback.domain.notification.config; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@EnableConfigurationProperties({ + ExpoPushProperties.class, + PushWorkerProperties.class, + PushReceiptWorkerProperties.class, + PushRecoveryWorkerProperties.class +}) +public class ExpoPushPropertiesConfig { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/config/PushReceiptWorkerProperties.java b/src/main/java/devkor/com/teamcback/domain/notification/config/PushReceiptWorkerProperties.java new file mode 100644 index 00000000..c543652b --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/config/PushReceiptWorkerProperties.java @@ -0,0 +1,22 @@ +package devkor.com.teamcback.domain.notification.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "push.receipt-worker") +public record PushReceiptWorkerProperties( + boolean enabled, + int batchSize, + int maxReceiptAttempts +) { + + private static final int MAX_EXPO_RECEIPT_BATCH_SIZE = 1000; + + public PushReceiptWorkerProperties { + if (batchSize <= 0 || batchSize > MAX_EXPO_RECEIPT_BATCH_SIZE) { + batchSize = MAX_EXPO_RECEIPT_BATCH_SIZE; + } + if (maxReceiptAttempts <= 0) { + maxReceiptAttempts = 3; + } + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/config/PushRecoveryWorkerProperties.java b/src/main/java/devkor/com/teamcback/domain/notification/config/PushRecoveryWorkerProperties.java new file mode 100644 index 00000000..5d4172df --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/config/PushRecoveryWorkerProperties.java @@ -0,0 +1,24 @@ +package devkor.com.teamcback.domain.notification.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "push.recovery-worker") +public record PushRecoveryWorkerProperties( + boolean enabled, + String cron, + long staleThresholdMinutes, + int batchSize +) { + + private static final int DEFAULT_BATCH_SIZE = 100; + private static final long DEFAULT_STALE_THRESHOLD_MINUTES = 30L; + + public PushRecoveryWorkerProperties { + if (staleThresholdMinutes <= 0) { + staleThresholdMinutes = DEFAULT_STALE_THRESHOLD_MINUTES; + } + if (batchSize <= 0) { + batchSize = DEFAULT_BATCH_SIZE; + } + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/config/PushWorkerProperties.java b/src/main/java/devkor/com/teamcback/domain/notification/config/PushWorkerProperties.java new file mode 100644 index 00000000..3845cf4e --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/config/PushWorkerProperties.java @@ -0,0 +1,26 @@ +package devkor.com.teamcback.domain.notification.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "push.worker") +public record PushWorkerProperties( + boolean enabled, + int batchSize, + int maxSendAttempts, + long retryDelayMs +) { + + private static final int MAX_EXPO_BATCH_SIZE = 100; + + public PushWorkerProperties { + if (batchSize <= 0 || batchSize > MAX_EXPO_BATCH_SIZE) { + batchSize = MAX_EXPO_BATCH_SIZE; + } + if (maxSendAttempts <= 0) { + maxSendAttempts = 3; + } + if (retryDelayMs <= 0) { + retryDelayMs = 30_000L; + } + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/controller/AdminNotificationController.java b/src/main/java/devkor/com/teamcback/domain/notification/controller/AdminNotificationController.java new file mode 100644 index 00000000..825f0a87 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/controller/AdminNotificationController.java @@ -0,0 +1,160 @@ +package devkor.com.teamcback.domain.notification.controller; + +import devkor.com.teamcback.domain.notification.dto.request.AdminPushDispatchReq; +import devkor.com.teamcback.domain.notification.dto.request.UpdatePushEventFlagReq; +import devkor.com.teamcback.domain.notification.dto.response.AdminPushDispatchDetailRes; +import devkor.com.teamcback.domain.notification.dto.response.AdminPushDispatchPreviewRes; +import devkor.com.teamcback.domain.notification.dto.response.AdminPushDispatchSummaryRes; +import devkor.com.teamcback.domain.notification.dto.response.AdminPushEventFlagRes; +import devkor.com.teamcback.domain.notification.dto.response.AdminPushInstallationRes; +import devkor.com.teamcback.domain.notification.dto.response.PushDispatchEnqueueRes; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.PushEventType; +import devkor.com.teamcback.domain.notification.entity.type.PushDispatchStatus; +import devkor.com.teamcback.domain.notification.service.AdminNotificationService; +import devkor.com.teamcback.domain.notification.service.PushEventFlagService; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import devkor.com.teamcback.global.response.CommonResponse; +import devkor.com.teamcback.global.response.ResultCode; +import devkor.com.teamcback.global.security.UserDetailsImpl; +import java.util.List; + +import io.swagger.v3.oas.annotations.Operation; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import static devkor.com.teamcback.global.response.ResultCode.UNAUTHORIZED; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/admin/notifications") +public class AdminNotificationController { + + private static final String DEFAULT_PAGE = "1"; + private static final String DEFAULT_SIZE = "20"; + + private final AdminNotificationService adminNotificationService; + private final PushEventFlagService pushEventFlagService; + + @Operation( + summary = "푸시 대상 installation 검색", + description = """ + userId 또는 installationId를 기준으로 + 푸시 발송 대상 기기를 조회합니다. + ExpoPushToken 원문은 응답하지 않습니다. + """ + ) + @GetMapping("/installations/search") + public CommonResponse> searchInstallations( + @RequestParam(required = false) Long userId, + @RequestParam(required = false) String installationId + ) { + return CommonResponse.success(adminNotificationService.searchInstallations(userId, installationId)); + } + + @Operation( + summary = "관리자 푸시 발송 미리보기", + description = """ + 푸시를 실제로 생성하지 않고 + 대상 기기 수와 최종 payload를 확인합니다. + PushDispatch와 PushMessage는 저장하지 않습니다. + """ + ) + @PostMapping("/dispatches/preview") + public CommonResponse preview( + @RequestBody AdminPushDispatchReq request + ) { + return CommonResponse.success(adminNotificationService.preview(request)); + } + + + @Operation( + summary = "관리자 푸시 수동 발송", + description = """ + 관리자가 입력한 내용으로 + PushDispatch와 PushMessage를 생성합니다. + 실제 Expo 전송은 기존 비동기 worker가 처리합니다. + """ + ) + @PostMapping("/dispatches") + public CommonResponse enqueue( + @AuthenticationPrincipal UserDetailsImpl userDetail, + @RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey, + @RequestBody AdminPushDispatchReq request + ) { + if (userDetail == null) { + throw new GlobalException(UNAUTHORIZED); + } + + return CommonResponse.success(adminNotificationService.enqueue( + userDetail.getUser().getUserId(), + idempotencyKey, + request + )); + } + + @Operation( + summary = "관리자 푸시 발송 이력 조회", + description = """ + 관리자 푸시 발송 내역을 최신순으로 조회합니다. + appVariant와 발송 상태로 필터링할 수 있습니다. + """ + ) + + @GetMapping("/dispatches") + public CommonResponse> getDispatches( + @RequestParam(defaultValue = DEFAULT_PAGE) int page, + @RequestParam(defaultValue = DEFAULT_SIZE) int size, + @RequestParam(required = false) AppVariant appVariant, + @RequestParam(required = false) PushDispatchStatus status + ) { + return CommonResponse.success(adminNotificationService.getDispatches( + page, + size, + appVariant, + status + )); + } + + @Operation( + summary = "관리자 푸시 발송 상세 조회", + description = """ + 발송 기본 정보와 전체 대상 수, + 메시지 상태별 처리 건수를 조회합니다. + ExpoPushToken과 개별 메시지 전체 목록은 반환하지 않습니다. + """ + ) + @GetMapping("/dispatches/{dispatchId}") + public CommonResponse getDispatch( + @PathVariable Long dispatchId + ) { + return CommonResponse.success(adminNotificationService.getDispatch(dispatchId)); + } + + @GetMapping("/event-flags") + public CommonResponse> getEventFlags() { + return CommonResponse.success(pushEventFlagService.getFlags()); + } + + @PatchMapping("/event-flags/{eventType}") + public CommonResponse updateEventFlag( + @PathVariable PushEventType eventType, + @RequestBody UpdatePushEventFlagReq request + ) { + if (request == null || request.enabled() == null) { + throw new GlobalException(ResultCode.INVALID_INPUT); + } + + return CommonResponse.success(pushEventFlagService.updateFlag(eventType, request.enabled())); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/controller/AdminSurveyPushScheduleController.java b/src/main/java/devkor/com/teamcback/domain/notification/controller/AdminSurveyPushScheduleController.java new file mode 100644 index 00000000..7840c637 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/controller/AdminSurveyPushScheduleController.java @@ -0,0 +1,44 @@ +package devkor.com.teamcback.domain.notification.controller; + +import devkor.com.teamcback.domain.notification.dto.request.AdminSurveyPushScheduleReq; +import devkor.com.teamcback.domain.notification.dto.response.AdminSurveyPushScheduleRes; +import devkor.com.teamcback.domain.notification.service.SurveyPushScheduleService; +import devkor.com.teamcback.global.response.CommonResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/admin/notifications/survey-schedules") +public class AdminSurveyPushScheduleController { + + private final SurveyPushScheduleService surveyPushScheduleService; + + @PutMapping("/{surveyKey}") + public CommonResponse upsertSurveySchedules( + @PathVariable String surveyKey, + @RequestBody AdminSurveyPushScheduleReq request + ) { + return CommonResponse.success(surveyPushScheduleService.upsertAdminSchedules(surveyKey, request)); + } + + @GetMapping("/{surveyKey}") + public CommonResponse getSurveySchedules( + @PathVariable String surveyKey + ) { + return CommonResponse.success(surveyPushScheduleService.getAdminSchedules(surveyKey)); + } + + @DeleteMapping("/{surveyKey}") + public CommonResponse cancelSurveySchedules( + @PathVariable String surveyKey + ) { + return CommonResponse.success(surveyPushScheduleService.cancelSchedules(surveyKey)); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/controller/NotificationTestController.java b/src/main/java/devkor/com/teamcback/domain/notification/controller/NotificationTestController.java new file mode 100644 index 00000000..9ff41f88 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/controller/NotificationTestController.java @@ -0,0 +1,71 @@ +package devkor.com.teamcback.domain.notification.controller; + +import devkor.com.teamcback.domain.notification.dto.request.NotificationTestReq; +import devkor.com.teamcback.domain.notification.dto.response.NotificationTestRes; +import devkor.com.teamcback.domain.notification.service.NotificationTestService; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import devkor.com.teamcback.global.response.CommonResponse; +import devkor.com.teamcback.global.security.UserDetailsImpl; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import static devkor.com.teamcback.global.response.ResultCode.UNAUTHORIZED; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/notifications") +public class NotificationTestController { + + private final NotificationTestService notificationTestService; + + /** + * DEV/PREVIEW 환경에서 실제 기기 푸시 수신을 확인하는 API입니다. + * 테스트 코드가 아닌 애플리케이션 기능입니다. + */ + @Operation( + summary = "푸시 알림 테스트 발송", + description = """ + 인증된 사용자의 본인 DEV/PREVIEW installation 한 대에 + 실제 테스트 푸시를 발송합니다. + PRODUCTION installation은 사용할 수 없습니다. + """ + ) + @PostMapping("/test") + public ResponseEntity> sendTest( + @Parameter(hidden = true) + @AuthenticationPrincipal UserDetailsImpl userDetail, + + @Parameter( + description = "중복 발송 방지를 위한 멱등성 키", + required = true, + example = "7b347ad7-6138-4cb7-af7d-f5201703a596" + ) + @RequestHeader(value = "Idempotency-Key", required = false) + String idempotencyKey, + + @Valid @RequestBody NotificationTestReq request + ) { + if (userDetail == null) { + throw new GlobalException(UNAUTHORIZED); + } + + Long userId = userDetail.getUser().getUserId(); + + return ResponseEntity.ok(CommonResponse.success( + notificationTestService.sendTest( + userId, + idempotencyKey, + request + ) + )); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/controller/PushInstallationController.java b/src/main/java/devkor/com/teamcback/domain/notification/controller/PushInstallationController.java new file mode 100644 index 00000000..2d9925e0 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/controller/PushInstallationController.java @@ -0,0 +1,126 @@ +package devkor.com.teamcback.domain.notification.controller; + + +import devkor.com.teamcback.domain.notification.dto.request.PushInstallationRegisterReq; +import devkor.com.teamcback.domain.notification.service.PushInstallationService; +import devkor.com.teamcback.global.response.CommonResponse; +import devkor.com.teamcback.global.security.UserDetailsImpl; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/* +@Tag( + name = "푸시 알림 설치 관리", + description = "로그인 사용자의 Expo Push installation 등록, 갱신 및 비활성화 API" +) + */ +@Validated +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/notifications/installations") +public class PushInstallationController { + + private final PushInstallationService pushInstallationService; + + @Operation( + summary = "푸시 알림 installation 등록 및 갱신", + description = """ + 현재 로그인 사용자의 Expo Push installation을 등록합니다. + 동일한 installationId로 다시 요청하면 새 행을 생성하지 않고 + 기존 installation의 토큰, 사용자 및 앱 환경 정보를 갱신합니다. + """ + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "등록 또는 갱신 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청값", + content = @Content(schema = @Schema(implementation = CommonResponse.class))), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = CommonResponse.class)) + ) + }) + @PutMapping("/{installationId}") + public ResponseEntity> register( + @Parameter(hidden = true) + @AuthenticationPrincipal UserDetailsImpl userDetail, + + @Parameter( + description = "앱 설치를 식별하는 고유 ID", + example = "11111111-2222-4333-8444-555555555555", + required = true + ) + @PathVariable + @NotBlank + @Size(max = 64) + String installationId, + + @Valid + @RequestBody + PushInstallationRegisterReq request + ) { + Long userId = userDetail.getUser().getUserId(); + + pushInstallationService.register( + userId, + installationId, + request.expoPushToken(), + request.appVariant() + ); + + return ResponseEntity.ok(CommonResponse.success()); + } + + @Operation( + summary = "푸시 알림 installation 비활성화", + description = """ + 현재 로그인 사용자가 소유한 installation을 비활성화합니다. + 대상이 없거나 이미 비활성화된 경우에도 성공으로 처리합니다. + """ + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "비활성화 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패", + content = @Content(schema = @Schema(implementation = CommonResponse.class)) + ) + }) + @DeleteMapping("/{installationId}") + public ResponseEntity> deactivate( + @Parameter(hidden = true) + @AuthenticationPrincipal UserDetailsImpl userDetail, + + @Parameter( + description = "비활성화할 앱 설치 식별자", + example = "11111111-2222-4333-8444-555555555555", + required = true + ) + @PathVariable + @NotBlank + @Size(max = 64) + String installationId + ) { + Long userId = userDetail.getUser().getUserId(); + + pushInstallationService.deactivate( + userId, + installationId + ); + + return ResponseEntity.ok(CommonResponse.success()); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/controller/SurveyNotificationController.java b/src/main/java/devkor/com/teamcback/domain/notification/controller/SurveyNotificationController.java new file mode 100644 index 00000000..a415ea11 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/controller/SurveyNotificationController.java @@ -0,0 +1,38 @@ +package devkor.com.teamcback.domain.notification.controller; + +import devkor.com.teamcback.domain.notification.dto.response.SurveyReminderRes; +import devkor.com.teamcback.domain.notification.service.SurveyPushScheduleService; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import devkor.com.teamcback.global.response.CommonResponse; +import devkor.com.teamcback.global.security.UserDetailsImpl; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import static devkor.com.teamcback.global.response.ResultCode.UNAUTHORIZED; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/notifications/surveys") +public class SurveyNotificationController { + + private final SurveyPushScheduleService surveyPushScheduleService; + + @PostMapping("/{surveyKey}/reminders") + public CommonResponse remindAfterLater( + @AuthenticationPrincipal UserDetailsImpl userDetail, + @PathVariable String surveyKey + ) { + if (userDetail == null) { + throw new GlobalException(UNAUTHORIZED); + } + + return CommonResponse.success(surveyPushScheduleService.remindAfterLater( + surveyKey, + userDetail.getUser().getUserId() + )); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushErrorDetails.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushErrorDetails.java new file mode 100644 index 00000000..7fe319fe --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushErrorDetails.java @@ -0,0 +1,9 @@ +package devkor.com.teamcback.domain.notification.dto.expo; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record ExpoPushErrorDetails( + String error +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushReceipt.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushReceipt.java new file mode 100644 index 00000000..2a4cdec9 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushReceipt.java @@ -0,0 +1,11 @@ +package devkor.com.teamcback.domain.notification.dto.expo; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record ExpoPushReceipt( + String status, + String message, + ExpoPushErrorDetails details +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushRequest.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushRequest.java new file mode 100644 index 00000000..98b19656 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushRequest.java @@ -0,0 +1,13 @@ +package devkor.com.teamcback.domain.notification.dto.expo; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record ExpoPushRequest( + String to, + String title, + String body, + String sound, + Object data +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushResponse.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushResponse.java new file mode 100644 index 00000000..25649876 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushResponse.java @@ -0,0 +1,10 @@ +package devkor.com.teamcback.domain.notification.dto.expo; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record ExpoPushResponse( + List data +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushTicket.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushTicket.java new file mode 100644 index 00000000..71bdae8b --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoPushTicket.java @@ -0,0 +1,12 @@ +package devkor.com.teamcback.domain.notification.dto.expo; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record ExpoPushTicket( + String status, + String id, + String message, + ExpoPushErrorDetails details +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoReceiptRequest.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoReceiptRequest.java new file mode 100644 index 00000000..11e0f82f --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoReceiptRequest.java @@ -0,0 +1,8 @@ +package devkor.com.teamcback.domain.notification.dto.expo; + +import java.util.List; + +public record ExpoReceiptRequest( + List ids +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoReceiptResponse.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoReceiptResponse.java new file mode 100644 index 00000000..e1dd3769 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/expo/ExpoReceiptResponse.java @@ -0,0 +1,10 @@ +package devkor.com.teamcback.domain.notification.dto.expo; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.util.Map; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record ExpoReceiptResponse( + Map data +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/payload/PushPayload.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/payload/PushPayload.java new file mode 100644 index 00000000..2f032793 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/payload/PushPayload.java @@ -0,0 +1,24 @@ +package devkor.com.teamcback.domain.notification.dto.payload; + +import java.util.Map; + +public record PushPayload( + String title, + String body, + PushPayloadData data +) { + + public record PushPayloadData( + int version, + String notificationId, + String appVariant, + PushPayloadAction action + ) { + } + + public record PushPayloadAction( + String type, + Map params + ) { + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/request/AdminPushDispatchReq.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/request/AdminPushDispatchReq.java new file mode 100644 index 00000000..80608a33 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/request/AdminPushDispatchReq.java @@ -0,0 +1,20 @@ +package devkor.com.teamcback.domain.notification.dto.request; + +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import java.util.Map; + +public record AdminPushDispatchReq( + PushMode mode, + AppVariant appVariant, + PushTargetType targetType, + String targetValue, + String title, + String body, + PushActionType actionType, + Map actionParams, + Boolean confirm +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/request/AdminSurveyPushScheduleReq.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/request/AdminSurveyPushScheduleReq.java new file mode 100644 index 00000000..d9c352af --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/request/AdminSurveyPushScheduleReq.java @@ -0,0 +1,10 @@ +package devkor.com.teamcback.domain.notification.dto.request; + +import java.time.LocalDateTime; + +public record AdminSurveyPushScheduleReq( + LocalDateTime startNotificationAt, + LocalDateTime deadlineNotificationAt, + Integer rewardPoint +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/request/NotificationTestReq.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/request/NotificationTestReq.java new file mode 100644 index 00000000..cf482998 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/request/NotificationTestReq.java @@ -0,0 +1,19 @@ +package devkor.com.teamcback.domain.notification.dto.request; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record NotificationTestReq( + @NotNull + @Min(1) + @Max(1) + Integer schemaVersion, + + @NotBlank + @Size(max = 64) + String installationId +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/request/PushDispatchCommand.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/request/PushDispatchCommand.java new file mode 100644 index 00000000..3eab2f55 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/request/PushDispatchCommand.java @@ -0,0 +1,23 @@ +package devkor.com.teamcback.domain.notification.dto.request; + +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.NotificationType; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import java.util.Map; + +public record PushDispatchCommand( + NotificationType notificationType, + PushMode mode, + AppVariant appVariant, + PushTargetType targetType, + String targetValue, + String title, + String body, + PushActionType actionType, + Map actionParams, + String idempotencyKey, + Long createdBy +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/request/PushInstallationRegisterReq.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/request/PushInstallationRegisterReq.java new file mode 100644 index 00000000..cafce71d --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/request/PushInstallationRegisterReq.java @@ -0,0 +1,33 @@ +package devkor.com.teamcback.domain.notification.dto.request; + +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "Expo Push installation 등록 요청") +public record PushInstallationRegisterReq( + + @Schema(description = "요청 스키마 버전", example = "1", allowableValues = {"1"}) + @NotNull + @Min(1) + @Max(1) + Integer schemaVersion, + + @Schema(description = "Expo에서 발급된 Push Token", example = "ExponentPushToken[xxxxxxxxxxxxxxxxxxxxxx]") + @NotBlank + String expoPushToken, + + @Schema(description = "토큰이 발급된 앱 빌드 환경", example = "dev", + allowableValues = { + "dev", + "preview", + "production" + } + ) + @NotNull + AppVariant appVariant +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/request/UpdatePushEventFlagReq.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/request/UpdatePushEventFlagReq.java new file mode 100644 index 00000000..70a5508e --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/request/UpdatePushEventFlagReq.java @@ -0,0 +1,6 @@ +package devkor.com.teamcback.domain.notification.dto.request; + +public record UpdatePushEventFlagReq( + Boolean enabled +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushDispatchDetailRes.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushDispatchDetailRes.java new file mode 100644 index 00000000..3ac470f8 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushDispatchDetailRes.java @@ -0,0 +1,58 @@ +package devkor.com.teamcback.domain.notification.dto.response; + +import devkor.com.teamcback.domain.notification.entity.PushDispatch; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.NotificationType; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushDispatchStatus; +import devkor.com.teamcback.domain.notification.entity.type.PushMessageStatus; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import java.time.LocalDateTime; +import java.util.Map; + +public record AdminPushDispatchDetailRes( + Long dispatchId, + NotificationType notificationType, + PushMode mode, + AppVariant appVariant, + PushTargetType targetType, + String targetValue, + String title, + String body, + PushActionType actionType, + String actionParams, + int recipientCount, + PushDispatchStatus status, + Map messageStatusCounts, + String idempotencyKey, + Long createdBy, + LocalDateTime createdAt, + LocalDateTime completedAt +) { + + public AdminPushDispatchDetailRes( + PushDispatch dispatch, + Map messageStatusCounts + ) { + this( + dispatch.getPushDispatchId(), + dispatch.getNotificationType(), + dispatch.getMode(), + dispatch.getAppVariant(), + dispatch.getTargetType(), + dispatch.getTargetValue(), + dispatch.getTitle(), + dispatch.getBody(), + dispatch.getActionType(), + dispatch.getActionParams(), + dispatch.getRecipientCount(), + dispatch.getStatus(), + messageStatusCounts, + dispatch.getIdempotencyKey(), + dispatch.getCreatedBy(), + dispatch.getCreatedAt(), + dispatch.getCompletedAt() + ); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushDispatchPreviewRes.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushDispatchPreviewRes.java new file mode 100644 index 00000000..3a32b5bf --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushDispatchPreviewRes.java @@ -0,0 +1,9 @@ +package devkor.com.teamcback.domain.notification.dto.response; + +import devkor.com.teamcback.domain.notification.dto.payload.PushPayload; + +public record AdminPushDispatchPreviewRes( + int recipientCount, + PushPayload payload +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushDispatchSummaryRes.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushDispatchSummaryRes.java new file mode 100644 index 00000000..c97e69c6 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushDispatchSummaryRes.java @@ -0,0 +1,47 @@ +package devkor.com.teamcback.domain.notification.dto.response; + +import devkor.com.teamcback.domain.notification.entity.PushDispatch; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.NotificationType; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushDispatchStatus; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import java.time.LocalDateTime; + +public record AdminPushDispatchSummaryRes( + Long dispatchId, + NotificationType notificationType, + PushMode mode, + AppVariant appVariant, + PushTargetType targetType, + String targetValue, + String title, + String body, + PushActionType actionType, + int recipientCount, + PushDispatchStatus status, + Long createdBy, + LocalDateTime createdAt, + LocalDateTime completedAt +) { + + public AdminPushDispatchSummaryRes(PushDispatch dispatch) { + this( + dispatch.getPushDispatchId(), + dispatch.getNotificationType(), + dispatch.getMode(), + dispatch.getAppVariant(), + dispatch.getTargetType(), + dispatch.getTargetValue(), + dispatch.getTitle(), + dispatch.getBody(), + dispatch.getActionType(), + dispatch.getRecipientCount(), + dispatch.getStatus(), + dispatch.getCreatedBy(), + dispatch.getCreatedAt(), + dispatch.getCompletedAt() + ); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushEventFlagRes.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushEventFlagRes.java new file mode 100644 index 00000000..79df8c2a --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushEventFlagRes.java @@ -0,0 +1,9 @@ +package devkor.com.teamcback.domain.notification.dto.response; + +import devkor.com.teamcback.domain.notification.entity.type.PushEventType; + +public record AdminPushEventFlagRes( + PushEventType eventType, + boolean enabled +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushInstallationRes.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushInstallationRes.java new file mode 100644 index 00000000..f2cc6bc9 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminPushInstallationRes.java @@ -0,0 +1,32 @@ +package devkor.com.teamcback.domain.notification.dto.response; + +import devkor.com.teamcback.domain.notification.entity.PushInstallation; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import java.time.LocalDateTime; + +public record AdminPushInstallationRes( + Long userId, + String installationId, + AppVariant appVariant, + boolean active, + LocalDateTime lastActiveAt, + LocalDateTime createdAt, + LocalDateTime modifiedAt, + LocalDateTime deactivatedAt +) { + + public AdminPushInstallationRes(PushInstallation installation) { + this( + installation.getUserId(), + installation.getInstallationId(), + installation.getAppVariant(), + installation.isActive(), + installation.isActive() + ? installation.getModifiedAt() + : installation.getDeactivatedAt(), + installation.getCreatedAt(), + installation.getModifiedAt(), + installation.getDeactivatedAt() + ); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminSurveyPushScheduleRes.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminSurveyPushScheduleRes.java new file mode 100644 index 00000000..3a2428b4 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminSurveyPushScheduleRes.java @@ -0,0 +1,10 @@ +package devkor.com.teamcback.domain.notification.dto.response; + +public record AdminSurveyPushScheduleRes( + String surveyKey, + int rewardPoint, + AdminSurveyPushScheduleStageRes started, + AdminSurveyPushScheduleStageRes dMinus3, + AdminSurveyPushScheduleStageRes deadline +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminSurveyPushScheduleStageRes.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminSurveyPushScheduleStageRes.java new file mode 100644 index 00000000..5428cc6b --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/AdminSurveyPushScheduleStageRes.java @@ -0,0 +1,21 @@ +package devkor.com.teamcback.domain.notification.dto.response; + +import devkor.com.teamcback.domain.notification.entity.SurveyPushSchedule; +import devkor.com.teamcback.domain.notification.entity.type.SurveyPushScheduleStatus; +import java.time.LocalDateTime; + +public record AdminSurveyPushScheduleStageRes( + SurveyPushScheduleStatus status, + LocalDateTime scheduledAt +) { + + public static AdminSurveyPushScheduleStageRes from(SurveyPushSchedule schedule) { + if (schedule == null) { + return null; + } + return new AdminSurveyPushScheduleStageRes( + schedule.getStatus(), + schedule.getScheduledAt() + ); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/NotificationTestRes.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/NotificationTestRes.java new file mode 100644 index 00000000..32d659d0 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/NotificationTestRes.java @@ -0,0 +1,13 @@ +package devkor.com.teamcback.domain.notification.dto.response; + +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.PushMessageStatus; + +public record NotificationTestRes( + String notificationId, + String installationId, + AppVariant appVariant, + PushMessageStatus messageStatus, + String ticketId +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/PushDispatchEnqueueRes.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/PushDispatchEnqueueRes.java new file mode 100644 index 00000000..37923008 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/PushDispatchEnqueueRes.java @@ -0,0 +1,21 @@ +package devkor.com.teamcback.domain.notification.dto.response; + +import devkor.com.teamcback.domain.notification.entity.PushDispatch; +import devkor.com.teamcback.domain.notification.entity.type.PushDispatchStatus; +import lombok.Getter; + +@Getter +public class PushDispatchEnqueueRes { + + private final Long dispatchId; + private final int recipientCount; + private final PushDispatchStatus status; + private final String idempotencyKey; + + public PushDispatchEnqueueRes(PushDispatch dispatch) { + this.dispatchId = dispatch.getPushDispatchId(); + this.recipientCount = dispatch.getRecipientCount(); + this.status = dispatch.getStatus(); + this.idempotencyKey = dispatch.getIdempotencyKey(); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/response/SurveyReminderRes.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/SurveyReminderRes.java new file mode 100644 index 00000000..0b1e704f --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/response/SurveyReminderRes.java @@ -0,0 +1,12 @@ +package devkor.com.teamcback.domain.notification.dto.response; + +import devkor.com.teamcback.domain.notification.entity.type.SurveyReminderSuppressedBy; +import java.time.LocalDateTime; + +public record SurveyReminderRes( + String surveyKey, + boolean scheduled, + LocalDateTime scheduledAt, + SurveyReminderSuppressedBy suppressedBy +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/worker/PushReceiptItem.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/worker/PushReceiptItem.java new file mode 100644 index 00000000..b6bda67b --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/worker/PushReceiptItem.java @@ -0,0 +1,8 @@ +package devkor.com.teamcback.domain.notification.dto.worker; + +public record PushReceiptItem( + Long pushMessageId, + Long pushDispatchId, + String expoTicketId +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/worker/PushReceiptWorkerResult.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/worker/PushReceiptWorkerResult.java new file mode 100644 index 00000000..0e22543a --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/worker/PushReceiptWorkerResult.java @@ -0,0 +1,11 @@ +package devkor.com.teamcback.domain.notification.dto.worker; + +public record PushReceiptWorkerResult( + int claimedCount, + int checkedCount +) { + + public static PushReceiptWorkerResult empty() { + return new PushReceiptWorkerResult(0, 0); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/worker/PushSendItem.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/worker/PushSendItem.java new file mode 100644 index 00000000..8a861bb5 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/worker/PushSendItem.java @@ -0,0 +1,10 @@ +package devkor.com.teamcback.domain.notification.dto.worker; + +import devkor.com.teamcback.domain.notification.dto.expo.ExpoPushRequest; + +public record PushSendItem( + Long pushMessageId, + Long pushDispatchId, + ExpoPushRequest request +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/dto/worker/PushWorkerDispatchResult.java b/src/main/java/devkor/com/teamcback/domain/notification/dto/worker/PushWorkerDispatchResult.java new file mode 100644 index 00000000..dccff995 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/dto/worker/PushWorkerDispatchResult.java @@ -0,0 +1,11 @@ +package devkor.com.teamcback.domain.notification.dto.worker; + +public record PushWorkerDispatchResult( + int claimedCount, + int sentCount +) { + + public static PushWorkerDispatchResult empty() { + return new PushWorkerDispatchResult(0, 0); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/PushDispatch.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/PushDispatch.java new file mode 100644 index 00000000..1a6a8468 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/PushDispatch.java @@ -0,0 +1,156 @@ +package devkor.com.teamcback.domain.notification.entity; + +import devkor.com.teamcback.domain.notification.entity.type.*; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.LocalDateTime; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "tb_push_dispatch", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_push_dispatch_idempotency_key", + columnNames = "idempotency_key" + ) + }, + indexes = { + @Index( + name = "idx_push_dispatch_status_created_at", + columnList = "status, created_at" + ) + } +) +@NoArgsConstructor +@Getter +public class PushDispatch { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "push_dispatch_id") + private Long pushDispatchId; + + @Enumerated(EnumType.STRING) + @Column(name = "notification_type", nullable = false, length = 40) + private NotificationType notificationType; + + @Enumerated(EnumType.STRING) + @Column(name = "mode", nullable = false, length = 20) + private PushMode mode; + + @Enumerated(EnumType.STRING) + @Column(name = "app_variant", nullable = false, length = 20) + private AppVariant appVariant; + + @Enumerated(EnumType.STRING) + @Column(name = "target_type", nullable = false, length = 30) + private PushTargetType targetType; + + @Column(name = "target_value", nullable = false, length = 128) + private String targetValue; + + @Column(name = "title", nullable = false, length = 200) + private String title; + + @Column(name = "body", nullable = false, length = 1024) + private String body; + + @Enumerated(EnumType.STRING) + @Column(name = "action_type", nullable = false, length = 40) + private PushActionType actionType; + + @Column(name = "action_params", nullable = false, length = 2048) + private String actionParams; + + @Column(name = "recipient_count", nullable = false) + private int recipientCount; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 30) + private PushDispatchStatus status; + + @Column(name = "idempotency_key", nullable = false, length = 128) + private String idempotencyKey; + + @Column(name = "created_by", nullable = false) + private Long createdBy; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "completed_at") + private LocalDateTime completedAt; + + public PushDispatch( + NotificationType notificationType, + PushMode mode, + AppVariant appVariant, + PushTargetType targetType, + String targetValue, + String title, + String body, + PushActionType actionType, + String actionParams, + String idempotencyKey, + Long createdBy, + LocalDateTime createdAt + ) { + this.notificationType = notificationType; + this.mode = mode; + this.appVariant = appVariant; + this.targetType = targetType; + this.targetValue = targetValue; + this.title = title; + this.body = body; + this.actionType = actionType; + this.actionParams = actionParams; + this.recipientCount = 0; + this.status = PushDispatchStatus.QUEUED; + this.idempotencyKey = idempotencyKey; + this.createdBy = createdBy; + this.createdAt = createdAt; + this.completedAt = null; + } + + public void updateRecipientCount(int recipientCount) { + this.recipientCount = recipientCount; + } + + public void updateStatusFromMessageSummary( + long queuedCount, + long sendingCount, + long successCount, + long failedCount, + LocalDateTime now + ) { + if (queuedCount > 0 || sendingCount > 0) { + this.status = PushDispatchStatus.PROCESSING; + return; + } + + if (failedCount == 0 && successCount == recipientCount) { + this.status = PushDispatchStatus.COMPLETED; + this.completedAt = now; + return; + } + + if (failedCount == recipientCount) { + this.status = PushDispatchStatus.FAILED; + this.completedAt = now; + return; + } + + this.status = PushDispatchStatus.PARTIAL_FAILED; + this.completedAt = now; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/PushInstallation.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/PushInstallation.java new file mode 100644 index 00000000..83d751ff --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/PushInstallation.java @@ -0,0 +1,94 @@ +package devkor.com.teamcback.domain.notification.entity; + +import devkor.com.teamcback.domain.common.entity.BaseEntity; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Table( + name = "tb_push_installation", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_push_installation_installation_id", + columnNames = "installation_id" + ), + @UniqueConstraint( + name = "uk_push_installation_expo_push_token", + columnNames = "expo_push_token" + ) + }, + indexes = { + @Index( + name = "idx_push_installation_user_variant_active", + columnList = "user_id, app_variant, active" + ) + } +) +@NoArgsConstructor +@Getter +public class PushInstallation extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "push_installation_id") + private Long pushInstallationId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "installation_id", nullable = false, length = 64) + private String installationId; + + @Column(name = "expo_push_token", nullable = false, length = 255) + private String expoPushToken; + + @Enumerated(EnumType.STRING) + @Column(name = "app_variant", nullable = false, length = 20) + private AppVariant appVariant; + + @Column(name = "active", nullable = false) + private boolean active = true; + + @Column(name = "deactivated_at") + private LocalDateTime deactivatedAt; + + public PushInstallation( + Long userId, + String installationId, + String expoPushToken, + AppVariant appVariant + ) { + this.userId = userId; + this.installationId = installationId; + this.expoPushToken = expoPushToken; + this.appVariant = appVariant; + this.active = true; + } + + public void register( + Long userId, + String installationId, + String expoPushToken, + AppVariant appVariant + ) { + this.userId = userId; + this.installationId = installationId; + this.expoPushToken = expoPushToken; + this.appVariant = appVariant; + this.active = true; + this.deactivatedAt = null; + } + + public void deactivate(LocalDateTime now) { + if (!active) { + return; + } + + this.active = false; + this.deactivatedAt = now; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/PushMessage.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/PushMessage.java new file mode 100644 index 00000000..d7a2f1c4 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/PushMessage.java @@ -0,0 +1,290 @@ +package devkor.com.teamcback.domain.notification.entity; + +import devkor.com.teamcback.domain.notification.entity.type.PushMessageStatus; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "tb_push_message", + indexes = { + @Index( + name = "idx_push_message_dispatch", + columnList = "push_dispatch_id" + ), + @Index( + name = "idx_push_message_status_next_retry_at", + columnList = "status, next_retry_at" + ) + } +) +@NoArgsConstructor +@Getter +public class PushMessage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "push_message_id") + private Long pushMessageId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "push_dispatch_id", nullable = false) + private PushDispatch dispatch; + + @Column(name = "push_installation_id", nullable = false) + private Long pushInstallationId; + + @Column(name = "installation_id", nullable = false, length = 64) + private String installationId; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 30) + private PushMessageStatus status; + + @Column(name = "expo_ticket_id", length = 255) + private String expoTicketId; + + @Column(name = "ticket_status", length = 40) + private String ticketStatus; + + @Column(name = "ticket_error", length = 1024) + private String ticketError; + + @Column(name = "receipt_status", length = 40) + private String receiptStatus; + + @Column(name = "receipt_error", length = 1024) + private String receiptError; + + @Column(name = "send_attempts", nullable = false) + private int sendAttempts; + + @Column(name = "receipt_attempts", nullable = false) + private int receiptAttempts; + + @Column(name = "next_retry_at") + private LocalDateTime nextRetryAt; + + @Column(name = "sent_at") + private LocalDateTime sentAt; + + @Column(name = "receipt_available_at") + private LocalDateTime receiptAvailableAt; + + @Column(name = "receipt_checked_at") + private LocalDateTime receiptCheckedAt; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + public PushMessage( + PushDispatch dispatch, + PushInstallation installation, + LocalDateTime now + ) { + this.dispatch = dispatch; + this.pushInstallationId = installation.getPushInstallationId(); + this.installationId = installation.getInstallationId(); + this.status = PushMessageStatus.QUEUED; + this.expoTicketId = null; + this.ticketStatus = null; + this.ticketError = null; + this.receiptStatus = null; + this.receiptError = null; + this.sendAttempts = 0; + this.receiptAttempts = 0; + this.nextRetryAt = null; + this.sentAt = null; + this.receiptAvailableAt = null; + this.receiptCheckedAt = null; + this.createdAt = now; + this.updatedAt = now; + } + + public void recordTicket( + String ticketStatus, + String expoTicketId, + String ticketError, + LocalDateTime now + ) { + this.ticketStatus = ticketStatus; + this.expoTicketId = expoTicketId; + this.ticketError = ticketError; + this.sendAttempts += 1; + this.sentAt = now; + this.updatedAt = now; + this.status = "ok".equals(ticketStatus) + ? PushMessageStatus.RECEIPT_PENDING + : PushMessageStatus.FAILED; + this.receiptAvailableAt = "ok".equals(ticketStatus) ? now.plusMinutes(15) : null; + this.nextRetryAt = null; + } + + public void markSending(LocalDateTime now) { + this.status = PushMessageStatus.SENDING; + this.updatedAt = now; + } + + public void markReceiptChecking(LocalDateTime now) { + this.status = PushMessageStatus.SENDING; + this.updatedAt = now; + } + + public void recordReceipt( + String receiptStatus, + String receiptError, + LocalDateTime now + ) { + this.receiptStatus = receiptStatus; + this.receiptError = "ok".equals(receiptStatus) ? null : receiptError; + this.receiptAttempts += 1; + this.receiptCheckedAt = now; + this.receiptAvailableAt = null; + this.updatedAt = now; + this.status = "ok".equals(receiptStatus) + ? PushMessageStatus.DELIVERED + : PushMessageStatus.FAILED; + } + + public void scheduleReceiptRetry( + String receiptStatus, + String receiptError, + int maxReceiptAttempts, + LocalDateTime nextReceiptAvailableAt, + LocalDateTime now + ) { + this.receiptStatus = receiptStatus; + this.receiptError = receiptError; + this.receiptAttempts += 1; + this.receiptCheckedAt = now; + this.updatedAt = now; + if (receiptAttempts < maxReceiptAttempts) { + this.status = PushMessageStatus.RECEIPT_PENDING; + this.receiptAvailableAt = nextReceiptAvailableAt; + return; + } + this.status = PushMessageStatus.FAILED; + this.receiptAvailableAt = null; + } + + public void recordReceiptExpired(LocalDateTime now) { + this.receiptStatus = "expired"; + this.receiptError = "receipt_expired"; + this.receiptCheckedAt = now; + this.receiptAvailableAt = null; + this.updatedAt = now; + this.status = PushMessageStatus.FAILED; + } + + public void recordRetryableTicketError( + String ticketStatus, + String ticketError, + int maxSendAttempts, + LocalDateTime nextRetryAt, + LocalDateTime now + ) { + this.ticketStatus = ticketStatus; + this.expoTicketId = null; + this.ticketError = ticketError; + this.sendAttempts += 1; + this.sentAt = now; + this.updatedAt = now; + if (sendAttempts < maxSendAttempts) { + this.status = PushMessageStatus.QUEUED; + this.nextRetryAt = nextRetryAt; + this.receiptAvailableAt = null; + return; + } + this.status = PushMessageStatus.FAILED; + this.nextRetryAt = null; + this.receiptAvailableAt = null; + } + + public void recordClientError( + boolean retryable, + LocalDateTime now + ) { + this.ticketStatus = "client_error"; + this.ticketError = retryable ? "retryable" : "non_retryable"; + this.sendAttempts += 1; + this.updatedAt = now; + this.status = PushMessageStatus.FAILED; + this.nextRetryAt = null; + this.receiptAvailableAt = null; + } + + public void recordClientError( + boolean retryable, + String ticketError, + int maxSendAttempts, + LocalDateTime nextRetryAt, + LocalDateTime now + ) { + this.ticketStatus = "client_error"; + this.expoTicketId = null; + this.ticketError = ticketError; + this.sendAttempts += 1; + this.updatedAt = now; + if (retryable && sendAttempts < maxSendAttempts) { + this.status = PushMessageStatus.QUEUED; + this.nextRetryAt = nextRetryAt; + this.receiptAvailableAt = null; + return; + } + this.status = PushMessageStatus.FAILED; + this.nextRetryAt = null; + this.receiptAvailableAt = null; + } + + public void recordSkipped( + String ticketStatus, + String ticketError, + LocalDateTime now + ) { + this.ticketStatus = ticketStatus; + this.expoTicketId = null; + this.ticketError = ticketError; + this.status = PushMessageStatus.FAILED; + this.nextRetryAt = null; + this.receiptAvailableAt = null; + this.updatedAt = now; + } + + public void recoverInterruptedSendingWithoutTicket( + String ticketError, + LocalDateTime now + ) { + this.ticketStatus = "worker_interrupted"; + this.expoTicketId = null; + this.ticketError = ticketError; + this.status = PushMessageStatus.FAILED; + this.nextRetryAt = null; + this.receiptAvailableAt = null; + this.updatedAt = now; + } + + public void recoverInterruptedSendingWithTicket(LocalDateTime now) { + this.status = PushMessageStatus.RECEIPT_PENDING; + this.nextRetryAt = null; + if (this.receiptAvailableAt == null) { + this.receiptAvailableAt = now; + } + this.updatedAt = now; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/SurveyPushSchedule.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/SurveyPushSchedule.java new file mode 100644 index 00000000..7ca41786 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/SurveyPushSchedule.java @@ -0,0 +1,122 @@ +package devkor.com.teamcback.domain.notification.entity; + +import devkor.com.teamcback.domain.notification.entity.type.SurveyNotificationStage; +import devkor.com.teamcback.domain.notification.entity.type.SurveyPushScheduleStatus; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.LocalDateTime; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "tb_survey_push_schedule", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_survey_push_schedule_idempotency_key", + columnNames = "idempotency_key" + ) + }, + indexes = { + @Index( + name = "idx_survey_push_schedule_status_scheduled_at", + columnList = "status, scheduled_at" + ) + } +) +@NoArgsConstructor +@Getter +public class SurveyPushSchedule { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "survey_push_schedule_id") + private Long surveyPushScheduleId; + + @Column(name = "survey_key", nullable = false, length = 64) + private String surveyKey; + + @Enumerated(EnumType.STRING) + @Column(name = "notification_stage", nullable = false, length = 40) + private SurveyNotificationStage notificationStage; + + @Column(name = "target_user_id") + private Long targetUserId; + + @Column(name = "scheduled_at", nullable = false) + private LocalDateTime scheduledAt; + + @Column(name = "reward_point", nullable = false) + private int rewardPoint; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 30) + private SurveyPushScheduleStatus status; + + @Column(name = "idempotency_key", nullable = false, length = 128) + private String idempotencyKey; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "processed_at") + private LocalDateTime processedAt; + + public SurveyPushSchedule( + String surveyKey, + SurveyNotificationStage notificationStage, + Long targetUserId, + LocalDateTime scheduledAt, + int rewardPoint, + String idempotencyKey, + LocalDateTime createdAt + ) { + this.surveyKey = surveyKey; + this.notificationStage = notificationStage; + this.targetUserId = targetUserId; + this.scheduledAt = scheduledAt; + this.rewardPoint = rewardPoint; + this.status = SurveyPushScheduleStatus.PENDING; + this.idempotencyKey = idempotencyKey; + this.createdAt = createdAt; + this.processedAt = null; + } + + public boolean isPending() { + return SurveyPushScheduleStatus.PENDING.equals(status); + } + + public void updatePendingSchedule( + LocalDateTime scheduledAt, + int rewardPoint + ) { + if (!isPending()) { + return; + } + this.scheduledAt = scheduledAt; + this.rewardPoint = rewardPoint; + } + + public void complete(LocalDateTime now) { + this.status = SurveyPushScheduleStatus.COMPLETED; + this.processedAt = now; + } + + public void cancel(LocalDateTime now) { + this.status = SurveyPushScheduleStatus.CANCELLED; + this.processedAt = now; + } + + public void skip(LocalDateTime now) { + this.status = SurveyPushScheduleStatus.SKIPPED; + this.processedAt = now; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/AppVariant.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/AppVariant.java new file mode 100644 index 00000000..d228d6f3 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/AppVariant.java @@ -0,0 +1,29 @@ +package devkor.com.teamcback.domain.notification.entity.type; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonValue; + +import java.util.Locale; + +public enum AppVariant { + + DEV, + PREVIEW, + PRODUCTION; + + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public static AppVariant from(String value) { + if (value == null) { + return null; + } + + return AppVariant.valueOf( + value.trim().toUpperCase(Locale.ROOT) + ); + } + + @JsonValue + public String toValue() { + return name().toLowerCase(Locale.ROOT); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/NotificationType.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/NotificationType.java new file mode 100644 index 00000000..8ea93bef --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/NotificationType.java @@ -0,0 +1,5 @@ +package devkor.com.teamcback.domain.notification.entity.type; + +public enum NotificationType { + GENERAL +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushActionType.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushActionType.java new file mode 100644 index 00000000..7b110df6 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushActionType.java @@ -0,0 +1,12 @@ +package devkor.com.teamcback.domain.notification.entity.type; + +public enum PushActionType { + HOME, + NOTICE, + MY_PAGE, + BUS_STOP, + BUILDING_DETAIL, + PLACE_DETAIL, + CHARACTER_STORE, + TEST +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushDispatchStatus.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushDispatchStatus.java new file mode 100644 index 00000000..91110c5e --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushDispatchStatus.java @@ -0,0 +1,9 @@ +package devkor.com.teamcback.domain.notification.entity.type; + +public enum PushDispatchStatus { + QUEUED, + PROCESSING, + COMPLETED, + PARTIAL_FAILED, + FAILED +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushEventType.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushEventType.java new file mode 100644 index 00000000..3445875f --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushEventType.java @@ -0,0 +1,18 @@ +package devkor.com.teamcback.domain.notification.entity.type; + +public enum PushEventType { + CROWD("push:event:crowd-enabled"), + REPORT("push:event:report-enabled"), + CHARACTER("push:event:character-enabled"), + SURVEY("push:event:survey-enabled"); + + private final String redisKey; + + PushEventType(String redisKey) { + this.redisKey = redisKey; + } + + public String redisKey() { + return redisKey; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushMessageStatus.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushMessageStatus.java new file mode 100644 index 00000000..7e750938 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushMessageStatus.java @@ -0,0 +1,10 @@ +package devkor.com.teamcback.domain.notification.entity.type; + +public enum PushMessageStatus { + QUEUED, + SENDING, + TICKET_RECEIVED, + RECEIPT_PENDING, + DELIVERED, + FAILED +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushMode.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushMode.java new file mode 100644 index 00000000..7068edf0 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushMode.java @@ -0,0 +1,6 @@ +package devkor.com.teamcback.domain.notification.entity.type; + +public enum PushMode { + TEST, + ACTUAL +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushTargetType.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushTargetType.java new file mode 100644 index 00000000..296d2715 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/PushTargetType.java @@ -0,0 +1,8 @@ +package devkor.com.teamcback.domain.notification.entity.type; + +public enum PushTargetType { + INSTALLATION, + USER, + USER_GROUP, + ALL +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/SurveyNotificationStage.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/SurveyNotificationStage.java new file mode 100644 index 00000000..8891eac6 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/SurveyNotificationStage.java @@ -0,0 +1,8 @@ +package devkor.com.teamcback.domain.notification.entity.type; + +public enum SurveyNotificationStage { + STARTED, + D_MINUS_3, + DEADLINE, + REMIND_AFTER_LATER +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/SurveyPushScheduleStatus.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/SurveyPushScheduleStatus.java new file mode 100644 index 00000000..8e06baed --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/SurveyPushScheduleStatus.java @@ -0,0 +1,8 @@ +package devkor.com.teamcback.domain.notification.entity.type; + +public enum SurveyPushScheduleStatus { + PENDING, + COMPLETED, + CANCELLED, + SKIPPED +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/entity/type/SurveyReminderSuppressedBy.java b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/SurveyReminderSuppressedBy.java new file mode 100644 index 00000000..61fb3b1f --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/entity/type/SurveyReminderSuppressedBy.java @@ -0,0 +1,10 @@ +package devkor.com.teamcback.domain.notification.entity.type; + +public enum SurveyReminderSuppressedBy { + NONE, + D3, + DEADLINE, + EXPIRED, + ALREADY_PROCESSED, + ALREADY_PARTICIPATED +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/factory/PushPayloadFactory.java b/src/main/java/devkor/com/teamcback/domain/notification/factory/PushPayloadFactory.java new file mode 100644 index 00000000..4f93898f --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/factory/PushPayloadFactory.java @@ -0,0 +1,123 @@ +package devkor.com.teamcback.domain.notification.factory; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import devkor.com.teamcback.domain.notification.dto.payload.PushPayload; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.domain.notification.validation.PushActionValidator; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import static devkor.com.teamcback.global.response.ResultCode.INVALID_INPUT; + +@Component +@RequiredArgsConstructor +public class PushPayloadFactory { + + private static final int PAYLOAD_VERSION = 1; + private static final int MAX_PAYLOAD_BYTES = 4096; + private static final String SIZE_VALIDATION_NOTIFICATION_ID = "00000000-0000-4000-8000-000000000000"; + + private final PushActionValidator actionValidator; + private final ObjectMapper objectMapper; + + public PushPayload create( + String notificationId, + String title, + String body, + PushMode mode, + AppVariant appVariant, + PushActionType actionType, + Map actionParams + ) { + validateText(notificationId); + validateText(title); + validateText(body); + + Map normalizedActionParams = actionValidator.validateAndNormalize( + actionType, + mode, + appVariant, + actionParams + ); + + PushPayload payload = new PushPayload( + title, + body, + new PushPayload.PushPayloadData( + PAYLOAD_VERSION, + notificationId, + appVariant.toValue(), + new PushPayload.PushPayloadAction( + actionType.name(), + normalizedActionParams + ) + ) + ); + + validatePayloadSize(payload); + return payload; + } + + public PushPayload createForPreDispatchValidation( + String title, + String body, + PushMode mode, + AppVariant appVariant, + PushActionType actionType, + Map actionParams + ) { + // PushMessage uses an identity Long, so the worker must create the final payload with the real message id and re-check size. + return create( + SIZE_VALIDATION_NOTIFICATION_ID, + title, + body, + mode, + appVariant, + actionType, + actionParams + ); + } + + public String serializeActionParams(Map actionParams) { + try { + return objectMapper.writeValueAsString(actionParams); + } catch (JsonProcessingException e) { + throw new GlobalException(INVALID_INPUT); + } + } + + public Map deserializeActionParams(String actionParams) { + if (actionParams == null || actionParams.isBlank()) { + return Map.of(); + } + + try { + return objectMapper.readValue(actionParams, new TypeReference<>() { + }); + } catch (JsonProcessingException e) { + throw new GlobalException(INVALID_INPUT); + } + } + + private void validateText(String value) { + if (value == null || value.isBlank()) { + throw new GlobalException(INVALID_INPUT); + } + } + + private void validatePayloadSize(PushPayload payload) { + try { + if (objectMapper.writeValueAsBytes(payload).length > MAX_PAYLOAD_BYTES) { + throw new GlobalException(INVALID_INPUT); + } + } catch (JsonProcessingException e) { + throw new GlobalException(INVALID_INPUT); + } + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListener.java b/src/main/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListener.java new file mode 100644 index 00000000..db24ed9a --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListener.java @@ -0,0 +1,91 @@ +package devkor.com.teamcback.domain.notification.listener; + +import devkor.com.teamcback.domain.character.event.CharacterUnlockedEvent; +import devkor.com.teamcback.domain.notification.dto.request.PushDispatchCommand; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.NotificationType; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushEventType; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.domain.notification.service.PushDispatchService; +import devkor.com.teamcback.domain.notification.service.PushEventFlagService; +import devkor.com.teamcback.domain.notification.template.DomainPushContentFactory; +import devkor.com.teamcback.domain.notification.template.PushContent; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CharacterUnlockedPushEventListener { + + private static final Long SYSTEM_CREATED_BY = 0L; + + private final PushInstallationRepository pushInstallationRepository; + private final PushDispatchService pushDispatchService; + private final PushEventFlagService pushEventFlagService; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(CharacterUnlockedEvent event) { + if (!pushEventFlagService.isEnabled(PushEventType.CHARACTER)) { + return; + } + + for (AppVariant targetAppVariant : targetAppVariants()) { + try { + if (!pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue( + event.userId(), + targetAppVariant + )) { + continue; + } + + PushContent content = DomainPushContentFactory.characterUnlocked(event.characterName()); + pushDispatchService.enqueue(new PushDispatchCommand( + NotificationType.GENERAL, + PushMode.ACTUAL, + targetAppVariant, + PushTargetType.USER, + String.valueOf(event.userId()), + content.title(), + content.body(), + PushActionType.CHARACTER_STORE, + Map.of(), + "character-unlock:%d:%d:%d:%s".formatted( + event.userId(), + event.characterId(), + event.userCharacterId(), + targetAppVariant.name().toLowerCase() + ), + SYSTEM_CREATED_BY + )); + } catch (Exception e) { + log.warn( + "character unlock push failed: userId={}, characterId={}, userCharacterId={}, appVariant={}, error={}", + event.userId(), + event.characterId(), + event.userCharacterId(), + targetAppVariant, + e.getMessage() + ); + } + } + } + + private List targetAppVariants() { + List configuredVariants = pushEventFlagService.getTargetAppVariants(); + return configuredVariants == null || configuredVariants.isEmpty() + ? List.of(AppVariant.PRODUCTION) + : configuredVariants; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListener.java b/src/main/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListener.java new file mode 100644 index 00000000..80c08dc6 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListener.java @@ -0,0 +1,128 @@ +package devkor.com.teamcback.domain.notification.listener; + +import devkor.com.teamcback.domain.ble.event.PlaceBecameVacantEvent; +import devkor.com.teamcback.domain.bookmark.repository.CategoryRepository; +import devkor.com.teamcback.domain.common.LocationType; +import devkor.com.teamcback.domain.notification.dto.request.PushDispatchCommand; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.NotificationType; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushEventType; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.domain.notification.service.PushEventFlagService; +import devkor.com.teamcback.domain.notification.service.PushDispatchService; +import devkor.com.teamcback.domain.notification.template.DomainPushContentFactory; +import devkor.com.teamcback.domain.notification.template.PushContent; +import devkor.com.teamcback.domain.place.entity.Place; +import devkor.com.teamcback.domain.place.repository.PlaceRepository; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CrowdVacantPushEventListener { + + private static final Long SYSTEM_CREATED_BY = 0L; + + private final PlaceRepository placeRepository; + private final CategoryRepository categoryRepository; + private final PushInstallationRepository pushInstallationRepository; + private final PushDispatchService pushDispatchService; + private final PushEventFlagService pushEventFlagService; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(PlaceBecameVacantEvent event) { + if (!pushEventFlagService.isEnabled(PushEventType.CROWD)) { + return; + } + + try { + Place place = placeRepository.findById(event.placeId()) + .orElse(null); + if (place == null) { + log.warn("crowd vacant push skipped: place not found, placeId={}", event.placeId()); + return; + } + + Set userIds = new LinkedHashSet<>( + categoryRepository.findDistinctUserIdsByLocationTypeAndLocationId( + LocationType.PLACE, + event.placeId() + ) + ); + if (userIds.isEmpty()) { + return; + } + + PushContent content = DomainPushContentFactory.placeBecameVacant( + place.getBuilding() == null ? null : place.getBuilding().getName(), + place.getName() + ); + for (Long userId : userIds) { + enqueueIfPushTargetExists(event, userId, content); + } + } catch (Exception e) { + log.warn( + "crowd vacant push failed: placeId={}, bleDataId={}, error={}", + event.placeId(), + event.bleDataId(), + e.getMessage() + ); + } + } + + private void enqueueIfPushTargetExists( + PlaceBecameVacantEvent event, + Long userId, + PushContent content + ) { + if (userId == null) { + return; + } + + for (AppVariant targetAppVariant : targetAppVariants()) { + if (!pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(userId, targetAppVariant)) { + continue; + } + + pushDispatchService.enqueue(new PushDispatchCommand( + NotificationType.GENERAL, + PushMode.ACTUAL, + targetAppVariant, + PushTargetType.USER, + String.valueOf(userId), + content.title(), + content.body(), + PushActionType.PLACE_DETAIL, + Map.of("placeId", event.placeId()), + "crowd-vacant:%d:%d:%d:%s".formatted( + event.placeId(), + userId, + event.bleDataId(), + targetAppVariant.name().toLowerCase() + ), + SYSTEM_CREATED_BY + )); + } + } + + private List targetAppVariants() { + List configuredVariants = pushEventFlagService.getTargetAppVariants(); + return configuredVariants == null || configuredVariants.isEmpty() + ? List.of(AppVariant.PRODUCTION) + : configuredVariants; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListener.java b/src/main/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListener.java new file mode 100644 index 00000000..1a58fd09 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListener.java @@ -0,0 +1,91 @@ +package devkor.com.teamcback.domain.notification.listener; + +import devkor.com.teamcback.domain.notification.dto.request.PushDispatchCommand; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.NotificationType; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushEventType; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.domain.notification.service.PushDispatchService; +import devkor.com.teamcback.domain.notification.service.PushEventFlagService; +import devkor.com.teamcback.domain.notification.template.DomainPushContentFactory; +import devkor.com.teamcback.domain.notification.template.PushContent; +import devkor.com.teamcback.domain.report.event.ReportResolvedEvent; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ReportResolvedPushEventListener { + + private static final Long SYSTEM_CREATED_BY = 0L; + + private final PushInstallationRepository pushInstallationRepository; + private final PushDispatchService pushDispatchService; + private final PushEventFlagService pushEventFlagService; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(ReportResolvedEvent event) { + if (!pushEventFlagService.isEnabled(PushEventType.REPORT) || event.reporterUserId() == null) { + return; + } + + for (AppVariant targetAppVariant : targetAppVariants()) { + try { + if (!pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue( + event.reporterUserId(), + targetAppVariant + )) { + continue; + } + + PushContent content = DomainPushContentFactory.reportResolved(); + pushDispatchService.enqueue(new PushDispatchCommand( + NotificationType.GENERAL, + PushMode.ACTUAL, + targetAppVariant, + PushTargetType.USER, + String.valueOf(event.reporterUserId()), + content.title(), + content.body(), + PushActionType.HOME, + Map.of(), + "report-result:%d:%s:%d:%s".formatted( + event.reportId(), + event.finalStatus().name(), + event.reporterUserId(), + targetAppVariant.name().toLowerCase() + ), + SYSTEM_CREATED_BY + )); + } catch (Exception e) { + log.warn( + "report result push failed: reportId={}, reporterUserId={}, finalStatus={}, appVariant={}, error={}", + event.reportId(), + event.reporterUserId(), + event.finalStatus(), + targetAppVariant, + e.getMessage() + ); + } + } + } + + private List targetAppVariants() { + List configuredVariants = pushEventFlagService.getTargetAppVariants(); + return configuredVariants == null || configuredVariants.isEmpty() + ? List.of(AppVariant.PRODUCTION) + : configuredVariants; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/repository/PushDispatchRepository.java b/src/main/java/devkor/com/teamcback/domain/notification/repository/PushDispatchRepository.java new file mode 100644 index 00000000..6571ae74 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/repository/PushDispatchRepository.java @@ -0,0 +1,31 @@ +package devkor.com.teamcback.domain.notification.repository; + +import devkor.com.teamcback.domain.notification.entity.PushDispatch; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.PushDispatchStatus; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface PushDispatchRepository extends JpaRepository { + + Optional findByIdempotencyKey( + String idempotencyKey + ); + + @Query(""" + SELECT d + FROM PushDispatch d + WHERE (:appVariant IS NULL OR d.appVariant = :appVariant) + AND (:status IS NULL OR d.status = :status) + ORDER BY d.createdAt DESC, d.pushDispatchId DESC + """) + Page findAdminDispatches( + @Param("appVariant") AppVariant appVariant, + @Param("status") PushDispatchStatus status, + Pageable pageable + ); +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/repository/PushInstallationRepository.java b/src/main/java/devkor/com/teamcback/domain/notification/repository/PushInstallationRepository.java new file mode 100644 index 00000000..96ec8433 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/repository/PushInstallationRepository.java @@ -0,0 +1,61 @@ +package devkor.com.teamcback.domain.notification.repository; + +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.PushInstallation; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface PushInstallationRepository extends JpaRepository { + + Optional findByInstallationId( + String installationId + ); + + Optional findByExpoPushToken( + String expoPushToken + ); + + Optional findByInstallationIdAndUserId( + String installationId, + Long userId + ); + + List findAllByUserIdAndActiveTrue( + Long userId + ); + + List findAllByUserIdOrderByModifiedAtDescPushInstallationIdDesc( + Long userId + ); + + Optional findByInstallationIdAndAppVariantAndActiveTrue( + String installationId, + AppVariant appVariant + ); + + List findAllByUserIdAndAppVariantAndActiveTrue( + Long userId, + AppVariant appVariant + ); + + List findAllByAppVariantAndActiveTrue( + AppVariant appVariant + ); + + boolean existsByUserIdAndAppVariantAndActiveTrue( + Long userId, + AppVariant appVariant + ); + + boolean existsByAppVariantAndActiveTrue( + AppVariant appVariant + ); + + Optional findByPushInstallationIdAndInstallationIdAndAppVariantAndActiveTrue( + Long pushInstallationId, + String installationId, + AppVariant appVariant + ); +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/repository/PushMessageRepository.java b/src/main/java/devkor/com/teamcback/domain/notification/repository/PushMessageRepository.java new file mode 100644 index 00000000..d9e0a380 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/repository/PushMessageRepository.java @@ -0,0 +1,98 @@ +package devkor.com.teamcback.domain.notification.repository; + +import devkor.com.teamcback.domain.notification.entity.PushDispatch; +import devkor.com.teamcback.domain.notification.entity.PushMessage; +import devkor.com.teamcback.domain.notification.entity.type.PushMessageStatus; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Collection; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface PushMessageRepository extends JpaRepository { + + List findAllByDispatch( + PushDispatch dispatch + ); + + @Query( + value = """ + SELECT * + FROM tb_push_message + WHERE status = 'QUEUED' + AND (next_retry_at IS NULL OR next_retry_at <= :now) + ORDER BY created_at ASC, push_message_id ASC + LIMIT :limit + FOR UPDATE SKIP LOCKED + """, + nativeQuery = true + ) + List findDueQueuedForUpdateSkipLocked( + @Param("now") LocalDateTime now, + @Param("limit") int limit + ); + + @Query( + value = """ + SELECT * + FROM tb_push_message + WHERE status = 'RECEIPT_PENDING' + AND expo_ticket_id IS NOT NULL + AND expo_ticket_id <> '' + AND receipt_available_at IS NOT NULL + AND receipt_available_at <= :now + ORDER BY receipt_available_at ASC, push_message_id ASC + LIMIT :limit + FOR UPDATE SKIP LOCKED + """, + nativeQuery = true + ) + List findDueReceiptPendingForUpdateSkipLocked( + @Param("now") LocalDateTime now, + @Param("limit") int limit + ); + + @Query( + value = """ + SELECT * + FROM tb_push_message + WHERE status = 'SENDING' + AND updated_at <= :staleBefore + ORDER BY updated_at ASC, push_message_id ASC + LIMIT :limit + FOR UPDATE SKIP LOCKED + """, + nativeQuery = true + ) + List findStaleSendingForUpdateSkipLocked( + @Param("staleBefore") LocalDateTime staleBefore, + @Param("limit") int limit + ); + + @EntityGraph(attributePaths = "dispatch") + List findAllByPushMessageIdIn( + Collection pushMessageIds + ); + + @Query(""" + SELECT m.dispatch.pushDispatchId AS dispatchId, + m.status AS status, + COUNT(m) AS count + FROM PushMessage m + WHERE m.dispatch.pushDispatchId IN :dispatchIds + GROUP BY m.dispatch.pushDispatchId, m.status + """) + List countStatusesByDispatchIds( + @Param("dispatchIds") Collection dispatchIds + ); + + interface PushDispatchMessageStatusCount { + Long getDispatchId(); + + PushMessageStatus getStatus(); + + long getCount(); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/repository/SurveyPushScheduleRepository.java b/src/main/java/devkor/com/teamcback/domain/notification/repository/SurveyPushScheduleRepository.java new file mode 100644 index 00000000..9cd0b446 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/repository/SurveyPushScheduleRepository.java @@ -0,0 +1,51 @@ +package devkor.com.teamcback.domain.notification.repository; + +import devkor.com.teamcback.domain.notification.entity.SurveyPushSchedule; +import devkor.com.teamcback.domain.notification.entity.type.SurveyNotificationStage; +import devkor.com.teamcback.domain.notification.entity.type.SurveyPushScheduleStatus; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface SurveyPushScheduleRepository extends JpaRepository { + + Optional findByIdempotencyKey(String idempotencyKey); + + List findAllBySurveyKeyOrderByNotificationStageAscSurveyPushScheduleIdAsc(String surveyKey); + + List findAllBySurveyKeyAndNotificationStageIn( + String surveyKey, + Collection notificationStages + ); + + Optional findBySurveyKeyAndNotificationStage( + String surveyKey, + SurveyNotificationStage notificationStage + ); + + @Query( + value = """ + SELECT * + FROM tb_survey_push_schedule + WHERE status = 'PENDING' + AND scheduled_at <= :now + ORDER BY scheduled_at ASC, survey_push_schedule_id ASC + LIMIT :limit + FOR UPDATE SKIP LOCKED + """, + nativeQuery = true + ) + List findDuePendingForUpdateSkipLocked( + @Param("now") LocalDateTime now, + @Param("limit") int limit + ); + + List findAllByStatusAndScheduledAtLessThanEqualOrderByScheduledAtAscSurveyPushScheduleIdAsc( + SurveyPushScheduleStatus status, + LocalDateTime now + ); +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/resolver/PushTargetResolver.java b/src/main/java/devkor/com/teamcback/domain/notification/resolver/PushTargetResolver.java new file mode 100644 index 00000000..60fe13b1 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/resolver/PushTargetResolver.java @@ -0,0 +1,124 @@ +package devkor.com.teamcback.domain.notification.resolver; + +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.PushInstallation; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import static devkor.com.teamcback.global.response.ResultCode.INVALID_INPUT; +import static devkor.com.teamcback.global.response.ResultCode.UNSUPPORTED_REQUEST; + +@Component +@RequiredArgsConstructor +public class PushTargetResolver { + + private final PushInstallationRepository pushInstallationRepository; + + public List resolve( + PushTargetType targetType, + String targetValue, + AppVariant appVariant + ) { + return resolve(targetType, targetValue, appVariant, false); + } + + public List resolveForPreview( + PushTargetType targetType, + String targetValue, + AppVariant appVariant + ) { + return resolve(targetType, targetValue, appVariant, true); + } + + private List resolve( + PushTargetType targetType, + String targetValue, + AppVariant appVariant, + boolean allowEmpty + ) { + if (targetType == null || targetValue == null || targetValue.isBlank() || appVariant == null) { + throw new GlobalException(INVALID_INPUT); + } + + List installations = switch (targetType) { + case INSTALLATION -> resolveInstallation(targetValue, appVariant); + case USER -> resolveUser(targetValue, appVariant); + case USER_GROUP -> throw new GlobalException(UNSUPPORTED_REQUEST); + case ALL -> resolveAll(targetValue, appVariant); + }; + + List distinctInstallations = distinctByInstallation(installations); + + if (distinctInstallations.isEmpty() && !allowEmpty) { + throw new GlobalException(INVALID_INPUT); + } + + return distinctInstallations; + } + + private List resolveInstallation( + String installationId, + AppVariant appVariant + ) { + return pushInstallationRepository.findByInstallationIdAndAppVariantAndActiveTrue( + installationId, + appVariant + ) + .map(List::of) + .orElseGet(List::of); + } + + private List resolveUser( + String targetValue, + AppVariant appVariant + ) { + Long userId = parsePositiveLong(targetValue); + + return pushInstallationRepository.findAllByUserIdAndAppVariantAndActiveTrue( + userId, + appVariant + ); + } + + private List resolveAll( + String targetValue, + AppVariant appVariant + ) { + if (!"ALL".equals(targetValue)) { + throw new GlobalException(INVALID_INPUT); + } + + return pushInstallationRepository.findAllByAppVariantAndActiveTrue(appVariant); + } + + private List distinctByInstallation(List installations) { + Map distinct = new LinkedHashMap<>(); + + installations.forEach(installation -> + distinct.putIfAbsent( + installation.getInstallationId(), + installation + ) + ); + + return distinct.values().stream().toList(); + } + + private Long parsePositiveLong(String value) { + try { + Long parsed = Long.parseLong(value); + if (parsed <= 0) { + throw new GlobalException(INVALID_INPUT); + } + return parsed; + } catch (NumberFormatException e) { + throw new GlobalException(INVALID_INPUT); + } + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/scheduler/PushMessageRecoveryScheduler.java b/src/main/java/devkor/com/teamcback/domain/notification/scheduler/PushMessageRecoveryScheduler.java new file mode 100644 index 00000000..29961b2e --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/scheduler/PushMessageRecoveryScheduler.java @@ -0,0 +1,20 @@ +package devkor.com.teamcback.domain.notification.scheduler; + +import devkor.com.teamcback.domain.notification.service.PushMessageRecoveryWorker; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(prefix = "push.recovery-worker", name = "enabled", havingValue = "true") +public class PushMessageRecoveryScheduler { + + private final PushMessageRecoveryWorker pushMessageRecoveryWorker; + + @Scheduled(cron = "${push.recovery-worker.cron}") + public void recoverStaleSendingMessages() { + pushMessageRecoveryWorker.recoverStaleSendingMessages(); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/scheduler/PushMessageWorkerScheduler.java b/src/main/java/devkor/com/teamcback/domain/notification/scheduler/PushMessageWorkerScheduler.java new file mode 100644 index 00000000..9b2067d8 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/scheduler/PushMessageWorkerScheduler.java @@ -0,0 +1,20 @@ +package devkor.com.teamcback.domain.notification.scheduler; + +import devkor.com.teamcback.domain.notification.service.PushMessageDispatchWorker; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(prefix = "push.worker", name = "enabled", havingValue = "true") +public class PushMessageWorkerScheduler { + + private final PushMessageDispatchWorker pushMessageDispatchWorker; + + @Scheduled(fixedDelayString = "${push.worker.fixed-delay-ms:5000}") + public void dispatchQueuedMessages() { + pushMessageDispatchWorker.dispatchPending(); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/scheduler/PushReceiptWorkerScheduler.java b/src/main/java/devkor/com/teamcback/domain/notification/scheduler/PushReceiptWorkerScheduler.java new file mode 100644 index 00000000..a7608f91 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/scheduler/PushReceiptWorkerScheduler.java @@ -0,0 +1,20 @@ +package devkor.com.teamcback.domain.notification.scheduler; + +import devkor.com.teamcback.domain.notification.service.PushReceiptWorker; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@ConditionalOnProperty(prefix = "push.receipt-worker", name = "enabled", havingValue = "true") +public class PushReceiptWorkerScheduler { + + private final PushReceiptWorker pushReceiptWorker; + + @Scheduled(fixedDelayString = "${push.receipt-worker.fixed-delay-ms:60000}") + public void checkPendingReceipts() { + pushReceiptWorker.checkPendingReceipts(); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/AdminNotificationService.java b/src/main/java/devkor/com/teamcback/domain/notification/service/AdminNotificationService.java new file mode 100644 index 00000000..c529ee4b --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/AdminNotificationService.java @@ -0,0 +1,206 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.dto.payload.PushPayload; +import devkor.com.teamcback.domain.notification.dto.request.AdminPushDispatchReq; +import devkor.com.teamcback.domain.notification.dto.request.PushDispatchCommand; +import devkor.com.teamcback.domain.notification.dto.response.AdminPushDispatchDetailRes; +import devkor.com.teamcback.domain.notification.dto.response.AdminPushDispatchPreviewRes; +import devkor.com.teamcback.domain.notification.dto.response.AdminPushDispatchSummaryRes; +import devkor.com.teamcback.domain.notification.dto.response.AdminPushInstallationRes; +import devkor.com.teamcback.domain.notification.dto.response.PushDispatchEnqueueRes; +import devkor.com.teamcback.domain.notification.entity.PushDispatch; +import devkor.com.teamcback.domain.notification.entity.PushInstallation; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.NotificationType; +import devkor.com.teamcback.domain.notification.entity.type.PushDispatchStatus; +import devkor.com.teamcback.domain.notification.entity.type.PushMessageStatus; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import devkor.com.teamcback.domain.notification.factory.PushPayloadFactory; +import devkor.com.teamcback.domain.notification.repository.PushDispatchRepository; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.domain.notification.repository.PushMessageRepository; +import devkor.com.teamcback.domain.notification.resolver.PushTargetResolver; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import static devkor.com.teamcback.global.response.ResultCode.FORBIDDEN; +import static devkor.com.teamcback.global.response.ResultCode.INVALID_INPUT; +import static devkor.com.teamcback.global.response.ResultCode.UNSUPPORTED_REQUEST; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminNotificationService { + + private final PushInstallationRepository pushInstallationRepository; + private final PushDispatchRepository pushDispatchRepository; + private final PushMessageRepository pushMessageRepository; + private final PushTargetResolver pushTargetResolver; + private final PushPayloadFactory pushPayloadFactory; + private final PushDispatchService pushDispatchService; + + @Value("${push.admin.production-enabled:false}") + private boolean productionEnabled; + + public List searchInstallations( + Long userId, + String installationId + ) { + if ((userId == null && !hasText(installationId)) + || (userId != null && hasText(installationId))) { + throw new GlobalException(INVALID_INPUT); + } + + if (userId != null) { + return pushInstallationRepository.findAllByUserIdOrderByModifiedAtDescPushInstallationIdDesc(userId) + .stream() + .map(AdminPushInstallationRes::new) + .toList(); + } + + return pushInstallationRepository.findByInstallationId(installationId) + .stream() + .map(AdminPushInstallationRes::new) + .toList(); + } + + public AdminPushDispatchPreviewRes preview(AdminPushDispatchReq request) { + validateTargetRules(request); + + List installations = pushTargetResolver.resolveForPreview( + request.targetType(), + request.targetValue(), + request.appVariant() + ); + + PushPayload payload = pushPayloadFactory.createForPreDispatchValidation( + request.title(), + request.body(), + request.mode(), + request.appVariant(), + request.actionType(), + request.actionParams() + ); + + return new AdminPushDispatchPreviewRes( + installations.size(), + payload + ); + } + + @Transactional + public PushDispatchEnqueueRes enqueue( + Long adminUserId, + String idempotencyKey, + AdminPushDispatchReq request + ) { + validateTargetRules(request); + validateProductionGate(request); + + return pushDispatchService.enqueue(new PushDispatchCommand( + NotificationType.GENERAL, + request.mode(), + request.appVariant(), + request.targetType(), + request.targetValue(), + request.title(), + request.body(), + request.actionType(), + request.actionParams(), + idempotencyKey, + adminUserId + )); + } + + public Page getDispatches( + int page, + int size, + AppVariant appVariant, + PushDispatchStatus status + ) { + if (page < 1 || size < 1) { + throw new GlobalException(INVALID_INPUT); + } + + Pageable pageable = PageRequest.of(page - 1, size); + return pushDispatchRepository.findAdminDispatches(appVariant, status, pageable) + .map(AdminPushDispatchSummaryRes::new); + } + + public AdminPushDispatchDetailRes getDispatch(Long dispatchId) { + if (dispatchId == null) { + throw new GlobalException(INVALID_INPUT); + } + + PushDispatch dispatch = pushDispatchRepository.findById(dispatchId) + .orElseThrow(() -> new GlobalException(INVALID_INPUT)); + + Map statusCounts = zeroStatusCounts(); + pushMessageRepository.countStatusesByDispatchIds(List.of(dispatchId)) + .forEach(count -> statusCounts.put(count.getStatus(), count.getCount())); + + return new AdminPushDispatchDetailRes(dispatch, statusCounts); + } + + private void validateTargetRules(AdminPushDispatchReq request) { + if (request == null + || request.mode() == null + || request.appVariant() == null + || request.targetType() == null) { + throw new GlobalException(INVALID_INPUT); + } + + if (PushTargetType.USER_GROUP.equals(request.targetType())) { + throw new GlobalException(UNSUPPORTED_REQUEST); + } + + if (PushMode.TEST.equals(request.mode()) + && !PushTargetType.INSTALLATION.equals(request.targetType())) { + throw new GlobalException(INVALID_INPUT); + } + + if (PushMode.ACTUAL.equals(request.mode()) + && !PushTargetType.INSTALLATION.equals(request.targetType()) + && !PushTargetType.USER.equals(request.targetType()) + && !PushTargetType.ALL.equals(request.targetType())) { + throw new GlobalException(UNSUPPORTED_REQUEST); + } + } + + private void validateProductionGate(AdminPushDispatchReq request) { + if (!PushMode.ACTUAL.equals(request.mode()) + || !AppVariant.PRODUCTION.equals(request.appVariant())) { + return; + } + + if (!productionEnabled) { + throw new GlobalException(FORBIDDEN); + } + + if (!Boolean.TRUE.equals(request.confirm())) { + throw new GlobalException(INVALID_INPUT); + } + } + + private Map zeroStatusCounts() { + Map statusCounts = new EnumMap<>(PushMessageStatus.class); + for (PushMessageStatus status : PushMessageStatus.values()) { + statusCounts.put(status, 0L); + } + return statusCounts; + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/NotificationTestService.java b/src/main/java/devkor/com/teamcback/domain/notification/service/NotificationTestService.java new file mode 100644 index 00000000..a3592b5a --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/NotificationTestService.java @@ -0,0 +1,205 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.dto.request.NotificationTestReq; +import devkor.com.teamcback.domain.notification.dto.response.NotificationTestRes; +import devkor.com.teamcback.domain.notification.entity.PushDispatch; +import devkor.com.teamcback.domain.notification.entity.PushInstallation; +import devkor.com.teamcback.domain.notification.entity.PushMessage; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.NotificationType; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import devkor.com.teamcback.domain.notification.factory.PushPayloadFactory; +import devkor.com.teamcback.domain.notification.repository.PushDispatchRepository; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.domain.notification.repository.PushMessageRepository; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; + +import static devkor.com.teamcback.global.response.ResultCode.EXPO_PUSH_NON_RETRYABLE_ERROR; +import static devkor.com.teamcback.global.response.ResultCode.FORBIDDEN_PUSH_INSTALLATION; +import static devkor.com.teamcback.global.response.ResultCode.INACTIVE_PUSH_INSTALLATION; +import static devkor.com.teamcback.global.response.ResultCode.INVALID_INPUT; +import static devkor.com.teamcback.global.response.ResultCode.NOT_FOUND_PUSH_INSTALLATION; +import static devkor.com.teamcback.global.response.ResultCode.UNSUPPORTED_PUSH_INSTALLATION_VARIANT; + +@Service +@RequiredArgsConstructor +public class NotificationTestService { + + private static final int MAX_IDEMPOTENCY_KEY_LENGTH = 128; + private static final int SCHEMA_VERSION = 1; + private static final String TEST_TITLE = "고대로 테스트 알림"; + private static final String TEST_BODY = "푸시 알림 연결이 정상적으로 동작합니다."; + + private final PushInstallationRepository pushInstallationRepository; + private final PushDispatchRepository pushDispatchRepository; + private final PushMessageRepository pushMessageRepository; + private final PushPayloadFactory pushPayloadFactory; + private final Clock clock; + + public NotificationTestRes sendTest( + Long userId, + String idempotencyKey, + NotificationTestReq request + ) { + validateRequest(userId, idempotencyKey, request); + + PushInstallation installation = findAndValidateInstallation( + userId, + request.installationId() + ); + + return pushDispatchRepository.findByIdempotencyKey(idempotencyKey) + .map(dispatch -> responseFromExistingDispatch(dispatch, installation)) + .orElseGet(() -> enqueue( + userId, + idempotencyKey, + installation + )); + } + + private NotificationTestRes enqueue( + Long userId, + String idempotencyKey, + PushInstallation installation + ) { + LocalDateTime now = LocalDateTime.now(clock); + + try { + PushDispatch dispatch = pushDispatchRepository.saveAndFlush(new PushDispatch( + NotificationType.GENERAL, + PushMode.TEST, + installation.getAppVariant(), + PushTargetType.INSTALLATION, + installation.getInstallationId(), + TEST_TITLE, + TEST_BODY, + PushActionType.TEST, + pushPayloadFactory.serializeActionParams(Collections.emptyMap()), + idempotencyKey, + userId, + now + )); + + PushMessage message = pushMessageRepository.saveAndFlush(new PushMessage( + dispatch, + installation, + now + )); + + dispatch.updateRecipientCount(1); + pushDispatchRepository.save(dispatch); + + return response(dispatch, message); + } catch (DataIntegrityViolationException e) { + return pushDispatchRepository.findByIdempotencyKey(idempotencyKey) + .map(dispatchFromRace -> responseFromExistingDispatch(dispatchFromRace, installation)) + .orElseThrow(() -> new GlobalException(EXPO_PUSH_NON_RETRYABLE_ERROR)); + } + } + + private NotificationTestRes responseFromExistingDispatch( + PushDispatch dispatch, + PushInstallation installation + ) { + List messages = pushMessageRepository.findAllByDispatch(dispatch); + + if (messages.size() != 1) { + throw new GlobalException(EXPO_PUSH_NON_RETRYABLE_ERROR); + } + + PushMessage message = messages.get(0); + + if (!installation.getInstallationId().equals(message.getInstallationId())) { + throw new GlobalException(INVALID_INPUT); + } + + return response(dispatch, message); + } + + private NotificationTestRes response( + PushDispatch dispatch, + PushMessage message + ) { + return new NotificationTestRes( + String.valueOf(message.getPushMessageId()), + message.getInstallationId(), + dispatch.getAppVariant(), + message.getStatus(), + message.getExpoTicketId() + ); + } + + private PushInstallation findAndValidateInstallation( + Long userId, + String installationId + ) { + PushInstallation installation = pushInstallationRepository.findByInstallationId(installationId) + .orElseThrow(() -> new GlobalException(NOT_FOUND_PUSH_INSTALLATION)); + + if (!userId.equals(installation.getUserId())) { + throw new GlobalException(FORBIDDEN_PUSH_INSTALLATION); + } + + if (!installation.isActive()) { + throw new GlobalException(INACTIVE_PUSH_INSTALLATION); + } + + if (AppVariant.PRODUCTION.equals(installation.getAppVariant())) { + throw new GlobalException(UNSUPPORTED_PUSH_INSTALLATION_VARIANT); + } + + if (!AppVariant.DEV.equals(installation.getAppVariant()) + && !AppVariant.PREVIEW.equals(installation.getAppVariant())) { + throw new GlobalException(UNSUPPORTED_PUSH_INSTALLATION_VARIANT); + } + + if (!hasText(installation.getExpoPushToken())) { + throw new GlobalException(INVALID_INPUT); + } + + return installation; + } + + private void validateRequest( + Long userId, + String idempotencyKey, + NotificationTestReq request + ) { + if (userId == null || request == null || request.schemaVersion() == null + || request.schemaVersion() != SCHEMA_VERSION) { + throw new GlobalException(INVALID_INPUT); + } + + validateText(request.installationId(), 64); + validateText(idempotencyKey, MAX_IDEMPOTENCY_KEY_LENGTH); + + try { + UUID.fromString(idempotencyKey); + } catch (IllegalArgumentException e) { + throw new GlobalException(INVALID_INPUT); + } + } + + private void validateText( + String value, + int maxLength + ) { + if (!hasText(value) || value.length() > maxLength) { + throw new GlobalException(INVALID_INPUT); + } + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushDispatchService.java b/src/main/java/devkor/com/teamcback/domain/notification/service/PushDispatchService.java new file mode 100644 index 00000000..e0127478 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/PushDispatchService.java @@ -0,0 +1,125 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.dto.payload.PushPayload; +import devkor.com.teamcback.domain.notification.dto.request.PushDispatchCommand; +import devkor.com.teamcback.domain.notification.dto.response.PushDispatchEnqueueRes; +import devkor.com.teamcback.domain.notification.entity.PushDispatch; +import devkor.com.teamcback.domain.notification.entity.PushInstallation; +import devkor.com.teamcback.domain.notification.entity.PushMessage; +import devkor.com.teamcback.domain.notification.factory.PushPayloadFactory; +import devkor.com.teamcback.domain.notification.repository.PushDispatchRepository; +import devkor.com.teamcback.domain.notification.repository.PushMessageRepository; +import devkor.com.teamcback.domain.notification.resolver.PushTargetResolver; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import static devkor.com.teamcback.global.response.ResultCode.INVALID_INPUT; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PushDispatchService { + + private static final int MAX_TITLE_LENGTH = 200; + private static final int MAX_BODY_LENGTH = 1024; + private static final int MAX_TARGET_VALUE_LENGTH = 128; + private static final int MAX_IDEMPOTENCY_KEY_LENGTH = 128; + + private final PushDispatchRepository pushDispatchRepository; + private final PushMessageRepository pushMessageRepository; + private final PushPayloadFactory pushPayloadFactory; + private final PushTargetResolver pushTargetResolver; + private final Clock clock; + + @Transactional + public PushDispatchEnqueueRes enqueue(PushDispatchCommand command) { + validateCommand(command); + + PushPayload payload = pushPayloadFactory.createForPreDispatchValidation( + command.title(), + command.body(), + command.mode(), + command.appVariant(), + command.actionType(), + command.actionParams() + ); + + return pushDispatchRepository.findByIdempotencyKey(command.idempotencyKey()) + .map(PushDispatchEnqueueRes::new) + .orElseGet(() -> createDispatch(command, payload)); + } + + private PushDispatchEnqueueRes createDispatch( + PushDispatchCommand command, + PushPayload payload + ) { + List installations = pushTargetResolver.resolve( + command.targetType(), + command.targetValue(), + command.appVariant() + ); + + LocalDateTime now = LocalDateTime.now(clock); + + PushDispatch dispatch = pushDispatchRepository.save( + new PushDispatch( + command.notificationType(), + command.mode(), + command.appVariant(), + command.targetType(), + command.targetValue(), + command.title(), + command.body(), + command.actionType(), + pushPayloadFactory.serializeActionParams(payload.data().action().params()), + command.idempotencyKey(), + command.createdBy(), + now + ) + ); + + List messages = installations.stream() + .map(installation -> new PushMessage( + dispatch, + installation, + now + )) + .toList(); + + pushMessageRepository.saveAll(messages); + dispatch.updateRecipientCount(messages.size()); + + return new PushDispatchEnqueueRes(dispatch); + } + + private void validateCommand(PushDispatchCommand command) { + if (command == null + || command.notificationType() == null + || command.mode() == null + || command.appVariant() == null + || command.targetType() == null + || command.actionType() == null + || command.createdBy() == null) { + throw new GlobalException(INVALID_INPUT); + } + + validateText(command.targetValue(), MAX_TARGET_VALUE_LENGTH); + validateText(command.title(), MAX_TITLE_LENGTH); + validateText(command.body(), MAX_BODY_LENGTH); + validateText(command.idempotencyKey(), MAX_IDEMPOTENCY_KEY_LENGTH); + } + + private void validateText( + String value, + int maxLength + ) { + if (value == null || value.isBlank() || value.length() > maxLength) { + throw new GlobalException(INVALID_INPUT); + } + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushEventFlagService.java b/src/main/java/devkor/com/teamcback/domain/notification/service/PushEventFlagService.java new file mode 100644 index 00000000..91f8cbb5 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/PushEventFlagService.java @@ -0,0 +1,88 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.dto.response.AdminPushEventFlagRes; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.PushEventType; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class PushEventFlagService { + + private final StringRedisTemplate redisTemplate; + + @Value("${push.event.crowd-enabled:false}") + private boolean crowdDefaultEnabled; + + @Value("${push.event.report-enabled:false}") + private boolean reportDefaultEnabled; + + @Value("${push.event.character-enabled:false}") + private boolean characterDefaultEnabled; + + @Value("${push.event.survey-enabled:false}") + private boolean surveyDefaultEnabled; + + @Value("${push.event.target-app-variants:PRODUCTION}") + private String targetAppVariants; + + public List getTargetAppVariants() { + List configuredVariants = Arrays.stream(targetAppVariants.split(",")) + .map(String::trim) + .filter(value -> !value.isEmpty()) + .map(value -> AppVariant.valueOf(value.toUpperCase(Locale.ROOT))) + .distinct() + .toList(); + return configuredVariants.isEmpty() + ? List.of(AppVariant.PRODUCTION) + : configuredVariants; + } + + public boolean isEnabled(PushEventType eventType) { + String redisValue = getRedisValue(eventType); + if ("true".equalsIgnoreCase(redisValue)) { + return true; + } + if ("false".equalsIgnoreCase(redisValue)) { + return false; + } + return defaultEnabled(eventType); + } + + public List getFlags() { + return Arrays.stream(PushEventType.values()) + .map(eventType -> new AdminPushEventFlagRes(eventType, isEnabled(eventType))) + .toList(); + } + + public AdminPushEventFlagRes updateFlag( + PushEventType eventType, + boolean enabled + ) { + redisTemplate.opsForValue().set(eventType.redisKey(), Boolean.toString(enabled)); + return new AdminPushEventFlagRes(eventType, isEnabled(eventType)); + } + + private String getRedisValue(PushEventType eventType) { + try { + return redisTemplate.opsForValue().get(eventType.redisKey()); + } catch (RuntimeException e) { + return null; + } + } + + private boolean defaultEnabled(PushEventType eventType) { + return switch (eventType) { + case CROWD -> crowdDefaultEnabled; + case REPORT -> reportDefaultEnabled; + case CHARACTER -> characterDefaultEnabled; + case SURVEY -> surveyDefaultEnabled; + }; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushInstallationService.java b/src/main/java/devkor/com/teamcback/domain/notification/service/PushInstallationService.java new file mode 100644 index 00000000..8a37340a --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/PushInstallationService.java @@ -0,0 +1,120 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.PushInstallation; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PushInstallationService { + + private final PushInstallationRepository repository; + private final Clock clock; + + @Transactional + public void register( + Long userId, + String installationId, + String expoPushToken, + AppVariant appVariant + ) { + Optional installationMatch = repository.findByInstallationId(installationId); + + Optional tokenMatch = repository.findByExpoPushToken(expoPushToken); + + if (installationMatch.isEmpty() && tokenMatch.isEmpty()) { + repository.save( + new PushInstallation( + userId, + installationId, + expoPushToken, + appVariant + ) + ); + return; + } + + if (installationMatch.isPresent() && tokenMatch.isEmpty()) { + installationMatch.get().register( + userId, + installationId, + expoPushToken, + appVariant + ); + return; + } + + if (installationMatch.isEmpty()) { + tokenMatch.get().register( + userId, + installationId, + expoPushToken, + appVariant + ); + return; + } + + PushInstallation installationEntity = installationMatch.get(); + + PushInstallation tokenEntity = tokenMatch.get(); + + if (installationEntity == tokenEntity + || installationEntity.getPushInstallationId() + .equals(tokenEntity.getPushInstallationId())) { + + installationEntity.register( + userId, + installationId, + expoPushToken, + appVariant + ); + return; + } + + repository.delete(tokenEntity); + repository.flush(); + + installationEntity.register( + userId, + installationId, + expoPushToken, + appVariant + ); + } + + @Transactional + public void deactivate( + Long userId, + String installationId + ) { + LocalDateTime now = LocalDateTime.now(clock); + + repository.findByInstallationIdAndUserId( + installationId, + userId + ) + .ifPresent(installation -> + installation.deactivate(now) + ); + } + + @Transactional + public void deactivateAll( + Long userId + ) { + LocalDateTime now = LocalDateTime.now(clock); + + repository.findAllByUserIdAndActiveTrue(userId) + .forEach(installation -> + installation.deactivate(now) + ); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageClaimService.java b/src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageClaimService.java new file mode 100644 index 00000000..f1301b39 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageClaimService.java @@ -0,0 +1,299 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.config.PushWorkerProperties; +import devkor.com.teamcback.domain.notification.dto.expo.ExpoPushRequest; +import devkor.com.teamcback.domain.notification.dto.expo.ExpoPushTicket; +import devkor.com.teamcback.domain.notification.dto.payload.PushPayload; +import devkor.com.teamcback.domain.notification.dto.worker.PushSendItem; +import devkor.com.teamcback.domain.notification.entity.PushDispatch; +import devkor.com.teamcback.domain.notification.entity.PushInstallation; +import devkor.com.teamcback.domain.notification.entity.PushMessage; +import devkor.com.teamcback.domain.notification.entity.type.PushMessageStatus; +import devkor.com.teamcback.domain.notification.factory.PushPayloadFactory; +import devkor.com.teamcback.domain.notification.repository.PushDispatchRepository; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.domain.notification.repository.PushMessageRepository; +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class PushMessageClaimService { + + private static final String DEFAULT_SOUND = "default"; + private static final String TICKET_STATUS_ERROR = "error"; + private static final String RETRYABLE_TICKET_ERROR = "MessageRateExceeded"; + private static final String DEVICE_NOT_REGISTERED_ERROR = "DeviceNotRegistered"; + + private final PushMessageRepository pushMessageRepository; + private final PushInstallationRepository pushInstallationRepository; + private final PushDispatchRepository pushDispatchRepository; + private final PushPayloadFactory pushPayloadFactory; + private final PushWorkerProperties pushWorkerProperties; + private final Clock clock; + + @Transactional + public List claimDueMessages() { + LocalDateTime now = LocalDateTime.now(clock); + List messages = pushMessageRepository.findDueQueuedForUpdateSkipLocked( + now, + pushWorkerProperties.batchSize() + ); + + if (messages.isEmpty()) { + return List.of(); + } + + Set dispatchIds = new HashSet<>(); + List sendItems = messages.stream() + .peek(message -> { + message.markSending(now); + dispatchIds.add(message.getDispatch().getPushDispatchId()); + }) + .map(message -> createSendItem(message, now)) + .filter(item -> item != null) + .toList(); + + refreshDispatchStatuses(dispatchIds, now); + return sendItems; + } + + @Transactional + public void recordTickets( + List items, + List tickets + ) { + if (items.isEmpty()) { + return; + } + + LocalDateTime now = LocalDateTime.now(clock); + LocalDateTime nextRetryAt = now.plusNanos(pushWorkerProperties.retryDelayMs() * 1_000_000L); + Map messageMap = findMessageMap(items); + Set dispatchIds = new HashSet<>(); + + for (int i = 0; i < items.size(); i += 1) { + PushSendItem item = items.get(i); + PushMessage message = messageMap.get(item.pushMessageId()); + if (message == null) { + continue; + } + + ExpoPushTicket ticket = tickets == null || i >= tickets.size() ? null : tickets.get(i); + String ticketError = ticketError(ticket); + if (isDeviceNotRegisteredTicket(ticket)) { + message.recordTicket( + ticket.status(), + ticket.id(), + ticketError, + now + ); + pushInstallationRepository.findById(message.getPushInstallationId()) + .ifPresent(installation -> installation.deactivate(now)); + } else if (isRetryableTicket(ticket)) { + message.recordRetryableTicketError( + ticket.status(), + ticketError, + pushWorkerProperties.maxSendAttempts(), + nextRetryAt, + now + ); + } else { + message.recordTicket( + ticket == null ? "missing_ticket" : ticket.status(), + ticket == null ? null : ticket.id(), + ticketError, + now + ); + } + dispatchIds.add(item.pushDispatchId()); + } + + refreshDispatchStatuses(dispatchIds, now); + } + + @Transactional + public void recordClientError( + List items, + boolean retryable, + String ticketError + ) { + if (items.isEmpty()) { + return; + } + + LocalDateTime now = LocalDateTime.now(clock); + LocalDateTime nextRetryAt = now.plusNanos(pushWorkerProperties.retryDelayMs() * 1_000_000L); + Map messageMap = findMessageMap(items); + Set dispatchIds = new HashSet<>(); + + items.forEach(item -> { + PushMessage message = messageMap.get(item.pushMessageId()); + if (message == null) { + return; + } + message.recordClientError( + retryable, + truncate(ticketError), + pushWorkerProperties.maxSendAttempts(), + nextRetryAt, + now + ); + dispatchIds.add(item.pushDispatchId()); + }); + + refreshDispatchStatuses(dispatchIds, now); + } + + private PushSendItem createSendItem( + PushMessage message, + LocalDateTime now + ) { + PushDispatch dispatch = message.getDispatch(); + PushInstallation installation = pushInstallationRepository + .findByPushInstallationIdAndInstallationIdAndAppVariantAndActiveTrue( + message.getPushInstallationId(), + message.getInstallationId(), + dispatch.getAppVariant() + ) + .orElse(null); + + if (installation == null) { + message.recordSkipped( + "installation_inactive", + "inactive_or_variant_mismatch", + now + ); + return null; + } + + try { + PushPayload payload = pushPayloadFactory.create( + String.valueOf(message.getPushMessageId()), + dispatch.getTitle(), + dispatch.getBody(), + dispatch.getMode(), + dispatch.getAppVariant(), + dispatch.getActionType(), + pushPayloadFactory.deserializeActionParams(dispatch.getActionParams()) + ); + + return new PushSendItem( + message.getPushMessageId(), + dispatch.getPushDispatchId(), + new ExpoPushRequest( + installation.getExpoPushToken(), + payload.title(), + payload.body(), + DEFAULT_SOUND, + payload.data() + ) + ); + } catch (RuntimeException e) { + message.recordSkipped( + "invalid_payload", + "payload_validation_failed", + now + ); + return null; + } + } + + private Map findMessageMap(List items) { + List messageIds = items.stream() + .map(PushSendItem::pushMessageId) + .toList(); + + return pushMessageRepository.findAllByPushMessageIdIn(messageIds) + .stream() + .collect(Collectors.toMap( + PushMessage::getPushMessageId, + message -> message + )); + } + + private void refreshDispatchStatuses( + Collection dispatchIds, + LocalDateTime now + ) { + if (dispatchIds.isEmpty()) { + return; + } + + Map> countsByDispatchId = new HashMap<>(); + pushMessageRepository.countStatusesByDispatchIds(dispatchIds) + .forEach(count -> countsByDispatchId + .computeIfAbsent( + count.getDispatchId(), + ignored -> new EnumMap<>(PushMessageStatus.class) + ) + .put(count.getStatus(), count.getCount())); + + pushDispatchRepository.findAllById(dispatchIds) + .forEach(dispatch -> { + EnumMap counts = countsByDispatchId.getOrDefault( + dispatch.getPushDispatchId(), + new EnumMap<>(PushMessageStatus.class) + ); + dispatch.updateStatusFromMessageSummary( + count(counts, PushMessageStatus.QUEUED) + + count(counts, PushMessageStatus.TICKET_RECEIVED) + + count(counts, PushMessageStatus.RECEIPT_PENDING), + count(counts, PushMessageStatus.SENDING), + count(counts, PushMessageStatus.DELIVERED), + count(counts, PushMessageStatus.FAILED), + now + ); + }); + } + + private long count( + EnumMap counts, + PushMessageStatus status + ) { + return counts.getOrDefault(status, 0L); + } + + private boolean isRetryableTicket(ExpoPushTicket ticket) { + return ticket != null + && TICKET_STATUS_ERROR.equals(ticket.status()) + && RETRYABLE_TICKET_ERROR.equals(ticketError(ticket)); + } + + private boolean isDeviceNotRegisteredTicket(ExpoPushTicket ticket) { + return ticket != null + && TICKET_STATUS_ERROR.equals(ticket.status()) + && ticket.details() != null + && DEVICE_NOT_REGISTERED_ERROR.equals(ticket.details().error()); + } + + private String ticketError(ExpoPushTicket ticket) { + if (ticket == null) { + return "missing_ticket"; + } + + if (ticket.details() != null && ticket.details().error() != null) { + return truncate(ticket.details().error()); + } + + return truncate(ticket.message()); + } + + private String truncate(String value) { + if (value == null || value.length() <= 1024) { + return value; + } + return value.substring(0, 1024); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageDispatchWorker.java b/src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageDispatchWorker.java new file mode 100644 index 00000000..e2e43f50 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageDispatchWorker.java @@ -0,0 +1,47 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.client.ExpoPushClient; +import devkor.com.teamcback.domain.notification.client.ExpoPushClientException; +import devkor.com.teamcback.domain.notification.dto.expo.ExpoPushResponse; +import devkor.com.teamcback.domain.notification.dto.worker.PushSendItem; +import devkor.com.teamcback.domain.notification.dto.worker.PushWorkerDispatchResult; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PushMessageDispatchWorker { + + private final PushMessageClaimService pushMessageClaimService; + private final ExpoPushClient expoPushClient; + + public PushWorkerDispatchResult dispatchPending() { + List items = pushMessageClaimService.claimDueMessages(); + if (items.isEmpty()) { + return PushWorkerDispatchResult.empty(); + } + + try { + ExpoPushResponse response = expoPushClient.send(items.stream() + .map(PushSendItem::request) + .toList()); + pushMessageClaimService.recordTickets(items, response.data()); + return new PushWorkerDispatchResult(items.size(), items.size()); + } catch (ExpoPushClientException e) { + pushMessageClaimService.recordClientError( + items, + e.isRetryable(), + e.getHttpStatus() == null ? e.getMessage() : "http_" + e.getHttpStatus() + ); + log.warn( + "Expo push send failed. retryable={}, itemCount={}", + e.isRetryable(), + items.size() + ); + return new PushWorkerDispatchResult(items.size(), 0); + } + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageRecoveryService.java b/src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageRecoveryService.java new file mode 100644 index 00000000..32aed3d4 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageRecoveryService.java @@ -0,0 +1,112 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.config.PushRecoveryWorkerProperties; +import devkor.com.teamcback.domain.notification.entity.PushDispatch; +import devkor.com.teamcback.domain.notification.entity.PushMessage; +import devkor.com.teamcback.domain.notification.entity.type.PushMessageStatus; +import devkor.com.teamcback.domain.notification.repository.PushDispatchRepository; +import devkor.com.teamcback.domain.notification.repository.PushMessageRepository; +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class PushMessageRecoveryService { + + private static final String WORKER_INTERRUPTED = "worker_interrupted"; + + private final PushMessageRepository pushMessageRepository; + private final PushDispatchRepository pushDispatchRepository; + private final PushRecoveryWorkerProperties pushRecoveryWorkerProperties; + private final Clock clock; + + @Transactional + public int recoverStaleSendingMessages() { + LocalDateTime now = LocalDateTime.now(clock); + LocalDateTime staleBefore = now.minusMinutes(pushRecoveryWorkerProperties.staleThresholdMinutes()); + List messages = pushMessageRepository.findStaleSendingForUpdateSkipLocked( + staleBefore, + pushRecoveryWorkerProperties.batchSize() + ); + + if (messages.isEmpty()) { + return 0; + } + + Set dispatchIds = new HashSet<>(); + messages.forEach(message -> { + dispatchIds.add(message.getDispatch().getPushDispatchId()); + if (hasText(message.getExpoTicketId())) { + message.recoverInterruptedSendingWithTicket(now); + return; + } + message.recoverInterruptedSendingWithoutTicket(WORKER_INTERRUPTED, now); + }); + + refreshDispatchStatuses(dispatchIds, now); + return messages.size(); + } + + private void refreshDispatchStatuses( + Collection dispatchIds, + LocalDateTime now + ) { + if (dispatchIds.isEmpty()) { + return; + } + + Map> countsByDispatchId = new HashMap<>(); + pushMessageRepository.countStatusesByDispatchIds(dispatchIds) + .forEach(count -> countsByDispatchId + .computeIfAbsent( + count.getDispatchId(), + ignored -> new EnumMap<>(PushMessageStatus.class) + ) + .put(count.getStatus(), count.getCount())); + + pushDispatchRepository.findAllById(dispatchIds) + .forEach(dispatch -> updateDispatchStatus(dispatch, countsByDispatchId, now)); + } + + private void updateDispatchStatus( + PushDispatch dispatch, + Map> countsByDispatchId, + LocalDateTime now + ) { + EnumMap counts = countsByDispatchId.getOrDefault( + dispatch.getPushDispatchId(), + new EnumMap<>(PushMessageStatus.class) + ); + + dispatch.updateStatusFromMessageSummary( + count(counts, PushMessageStatus.QUEUED) + + count(counts, PushMessageStatus.TICKET_RECEIVED) + + count(counts, PushMessageStatus.RECEIPT_PENDING), + count(counts, PushMessageStatus.SENDING), + count(counts, PushMessageStatus.DELIVERED), + count(counts, PushMessageStatus.FAILED), + now + ); + } + + private long count( + EnumMap counts, + PushMessageStatus status + ) { + return counts.getOrDefault(status, 0L); + } + + private boolean hasText(String value) { + return value != null && !value.isBlank(); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageRecoveryWorker.java b/src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageRecoveryWorker.java new file mode 100644 index 00000000..6ac46825 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/PushMessageRecoveryWorker.java @@ -0,0 +1,21 @@ +package devkor.com.teamcback.domain.notification.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PushMessageRecoveryWorker { + + private final PushMessageRecoveryService pushMessageRecoveryService; + + public int recoverStaleSendingMessages() { + int recoveredCount = pushMessageRecoveryService.recoverStaleSendingMessages(); + if (recoveredCount > 0) { + log.warn("Recovered stale SENDING push messages. count={}", recoveredCount); + } + return recoveredCount; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushReceiptClaimService.java b/src/main/java/devkor/com/teamcback/domain/notification/service/PushReceiptClaimService.java new file mode 100644 index 00000000..fb477a4d --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/PushReceiptClaimService.java @@ -0,0 +1,281 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.config.PushReceiptWorkerProperties; +import devkor.com.teamcback.domain.notification.dto.expo.ExpoPushReceipt; +import devkor.com.teamcback.domain.notification.dto.worker.PushReceiptItem; +import devkor.com.teamcback.domain.notification.entity.PushDispatch; +import devkor.com.teamcback.domain.notification.entity.PushMessage; +import devkor.com.teamcback.domain.notification.entity.type.PushMessageStatus; +import devkor.com.teamcback.domain.notification.repository.PushDispatchRepository; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.domain.notification.repository.PushMessageRepository; +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.EnumMap; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class PushReceiptClaimService { + + private static final String RECEIPT_STATUS_OK = "ok"; + private static final String RECEIPT_STATUS_ERROR = "error"; + private static final String RECEIPT_STATUS_NOT_READY = "not_ready"; + private static final String CLIENT_ERROR_STATUS = "client_error"; + private static final String DEVICE_NOT_REGISTERED_ERROR = "DeviceNotRegistered"; + + private final PushMessageRepository pushMessageRepository; + private final PushInstallationRepository pushInstallationRepository; + private final PushDispatchRepository pushDispatchRepository; + private final PushReceiptWorkerProperties pushReceiptWorkerProperties; + private final Clock clock; + + @Transactional + public List claimDueReceipts() { + LocalDateTime now = LocalDateTime.now(clock); + List messages = pushMessageRepository.findDueReceiptPendingForUpdateSkipLocked( + now, + pushReceiptWorkerProperties.batchSize() + ); + + if (messages.isEmpty()) { + return List.of(); + } + + Set dispatchIds = new HashSet<>(); + List receiptItems = messages.stream() + .map(message -> claimMessage(message, now, dispatchIds)) + .filter(item -> item != null) + .toList(); + + refreshDispatchStatuses(dispatchIds, now); + return receiptItems; + } + + @Transactional + public void recordReceipts( + List items, + Map receiptMap + ) { + if (items.isEmpty()) { + return; + } + + LocalDateTime now = LocalDateTime.now(clock); + Map messageMap = findMessageMap(items); + Set dispatchIds = new HashSet<>(); + + for (PushReceiptItem item : items) { + PushMessage message = messageMap.get(item.pushMessageId()); + if (message == null) { + continue; + } + + ExpoPushReceipt receipt = receiptMap == null ? null : receiptMap.get(item.expoTicketId()); + if (receipt == null) { + scheduleRetry(message, RECEIPT_STATUS_NOT_READY, "receipt_not_ready", now); + } else if (isDeviceNotRegisteredReceipt(receipt)) { + message.recordReceipt( + receipt.status(), + receiptError(receipt), + now + ); + pushInstallationRepository.findById(message.getPushInstallationId()) + .ifPresent(installation -> installation.deactivate(now)); + } else { + message.recordReceipt( + receipt.status(), + receiptError(receipt), + now + ); + } + dispatchIds.add(item.pushDispatchId()); + } + + refreshDispatchStatuses(dispatchIds, now); + } + + @Transactional + public void recordClientError( + List items, + boolean retryable, + String receiptError + ) { + if (items.isEmpty()) { + return; + } + + LocalDateTime now = LocalDateTime.now(clock); + Map messageMap = findMessageMap(items); + Set dispatchIds = new HashSet<>(); + + items.forEach(item -> { + PushMessage message = messageMap.get(item.pushMessageId()); + if (message == null) { + return; + } + if (retryable) { + scheduleRetry(message, CLIENT_ERROR_STATUS, truncate(receiptError), now); + } else { + message.recordReceipt( + CLIENT_ERROR_STATUS, + truncate(receiptError), + now + ); + } + dispatchIds.add(item.pushDispatchId()); + }); + + refreshDispatchStatuses(dispatchIds, now); + } + + private PushReceiptItem claimMessage( + PushMessage message, + LocalDateTime now, + Set dispatchIds + ) { + dispatchIds.add(message.getDispatch().getPushDispatchId()); + + if (isReceiptExpired(message, now)) { + message.recordReceiptExpired(now); + return null; + } + + message.markReceiptChecking(now); + return new PushReceiptItem( + message.getPushMessageId(), + message.getDispatch().getPushDispatchId(), + message.getExpoTicketId() + ); + } + + private void scheduleRetry( + PushMessage message, + String receiptStatus, + String receiptError, + LocalDateTime now + ) { + if (isReceiptExpired(message, now)) { + message.recordReceiptExpired(now); + return; + } + + message.scheduleReceiptRetry( + receiptStatus, + receiptError, + pushReceiptWorkerProperties.maxReceiptAttempts(), + now.plusMinutes(nextBackoffMinutes(message.getReceiptAttempts())), + now + ); + } + + private long nextBackoffMinutes(int currentReceiptAttempts) { + int retryNumber = Math.max(0, currentReceiptAttempts); + return 1L << Math.min(retryNumber, 2); + } + + private boolean isReceiptExpired( + PushMessage message, + LocalDateTime now + ) { + return message.getSentAt() == null + || message.getSentAt().plusHours(24).isBefore(now); + } + + private Map findMessageMap(List items) { + List messageIds = items.stream() + .map(PushReceiptItem::pushMessageId) + .toList(); + + return pushMessageRepository.findAllByPushMessageIdIn(messageIds) + .stream() + .collect(Collectors.toMap( + PushMessage::getPushMessageId, + message -> message + )); + } + + private void refreshDispatchStatuses( + Collection dispatchIds, + LocalDateTime now + ) { + if (dispatchIds.isEmpty()) { + return; + } + + Map> countsByDispatchId = new HashMap<>(); + pushMessageRepository.countStatusesByDispatchIds(dispatchIds) + .forEach(count -> countsByDispatchId + .computeIfAbsent( + count.getDispatchId(), + ignored -> new EnumMap<>(PushMessageStatus.class) + ) + .put(count.getStatus(), count.getCount())); + + pushDispatchRepository.findAllById(dispatchIds) + .forEach(dispatch -> updateDispatchStatus(dispatch, countsByDispatchId, now)); + } + + private void updateDispatchStatus( + PushDispatch dispatch, + Map> countsByDispatchId, + LocalDateTime now + ) { + EnumMap counts = countsByDispatchId.getOrDefault( + dispatch.getPushDispatchId(), + new EnumMap<>(PushMessageStatus.class) + ); + + dispatch.updateStatusFromMessageSummary( + count(counts, PushMessageStatus.QUEUED) + + count(counts, PushMessageStatus.TICKET_RECEIVED) + + count(counts, PushMessageStatus.RECEIPT_PENDING), + count(counts, PushMessageStatus.SENDING), + count(counts, PushMessageStatus.DELIVERED), + count(counts, PushMessageStatus.FAILED), + now + ); + } + + private long count( + EnumMap counts, + PushMessageStatus status + ) { + return counts.getOrDefault(status, 0L); + } + + private boolean isDeviceNotRegisteredReceipt(ExpoPushReceipt receipt) { + return receipt != null + && RECEIPT_STATUS_ERROR.equals(receipt.status()) + && receipt.details() != null + && DEVICE_NOT_REGISTERED_ERROR.equals(receipt.details().error()); + } + + private String receiptError(ExpoPushReceipt receipt) { + if (receipt == null || RECEIPT_STATUS_OK.equals(receipt.status())) { + return null; + } + + if (receipt.details() != null && receipt.details().error() != null) { + return truncate(receipt.details().error()); + } + + return truncate(receipt.message()); + } + + private String truncate(String value) { + if (value == null || value.length() <= 1024) { + return value; + } + return value.substring(0, 1024); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/PushReceiptWorker.java b/src/main/java/devkor/com/teamcback/domain/notification/service/PushReceiptWorker.java new file mode 100644 index 00000000..a5319bad --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/PushReceiptWorker.java @@ -0,0 +1,47 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.client.ExpoPushClient; +import devkor.com.teamcback.domain.notification.client.ExpoPushClientException; +import devkor.com.teamcback.domain.notification.dto.expo.ExpoReceiptResponse; +import devkor.com.teamcback.domain.notification.dto.worker.PushReceiptItem; +import devkor.com.teamcback.domain.notification.dto.worker.PushReceiptWorkerResult; +import java.util.List; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PushReceiptWorker { + + private final PushReceiptClaimService pushReceiptClaimService; + private final ExpoPushClient expoPushClient; + + public PushReceiptWorkerResult checkPendingReceipts() { + List items = pushReceiptClaimService.claimDueReceipts(); + if (items.isEmpty()) { + return PushReceiptWorkerResult.empty(); + } + + try { + ExpoReceiptResponse response = expoPushClient.getReceipts(items.stream() + .map(PushReceiptItem::expoTicketId) + .toList()); + pushReceiptClaimService.recordReceipts(items, response.data()); + return new PushReceiptWorkerResult(items.size(), items.size()); + } catch (ExpoPushClientException e) { + pushReceiptClaimService.recordClientError( + items, + e.isRetryable(), + e.getHttpStatus() == null ? e.getMessage() : "http_" + e.getHttpStatus() + ); + log.warn( + "Expo push receipt request failed. retryable={}, itemCount={}", + e.isRetryable(), + items.size() + ); + return new PushReceiptWorkerResult(items.size(), 0); + } + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleService.java b/src/main/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleService.java new file mode 100644 index 00000000..b27189b4 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleService.java @@ -0,0 +1,280 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.dto.request.AdminSurveyPushScheduleReq; +import devkor.com.teamcback.domain.notification.dto.response.AdminSurveyPushScheduleRes; +import devkor.com.teamcback.domain.notification.dto.response.AdminSurveyPushScheduleStageRes; +import devkor.com.teamcback.domain.notification.dto.response.SurveyReminderRes; +import devkor.com.teamcback.domain.notification.entity.SurveyPushSchedule; +import devkor.com.teamcback.domain.notification.entity.type.SurveyNotificationStage; +import devkor.com.teamcback.domain.notification.entity.type.SurveyReminderSuppressedBy; +import devkor.com.teamcback.domain.notification.repository.SurveyPushScheduleRepository; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import java.time.Clock; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import static devkor.com.teamcback.global.response.ResultCode.INVALID_INPUT; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SurveyPushScheduleService { + + private static final int MAX_SURVEY_KEY_LENGTH = 64; + private static final List ADMIN_STAGES = List.of( + SurveyNotificationStage.STARTED, + SurveyNotificationStage.D_MINUS_3, + SurveyNotificationStage.DEADLINE + ); + + private final SurveyPushScheduleRepository surveyPushScheduleRepository; + private final Clock clock; + + @Transactional + public AdminSurveyPushScheduleRes upsertAdminSchedules( + String surveyKey, + AdminSurveyPushScheduleReq request + ) { + validateSurveyKey(surveyKey); + validateAdminRequest(request); + + LocalDateTime dMinus3At = request.deadlineNotificationAt().minusDays(3); + if (!request.startNotificationAt().isBefore(dMinus3At)) { + throw new GlobalException(INVALID_INPUT); + } + + upsertSchedule( + surveyKey, + SurveyNotificationStage.STARTED, + null, + request.startNotificationAt(), + request.rewardPoint() + ); + upsertSchedule( + surveyKey, + SurveyNotificationStage.D_MINUS_3, + null, + dMinus3At, + request.rewardPoint() + ); + upsertSchedule( + surveyKey, + SurveyNotificationStage.DEADLINE, + null, + request.deadlineNotificationAt(), + request.rewardPoint() + ); + + return getAdminSchedules(surveyKey); + } + + public AdminSurveyPushScheduleRes getAdminSchedules(String surveyKey) { + validateSurveyKey(surveyKey); + + Map schedules = new EnumMap<>(SurveyNotificationStage.class); + surveyPushScheduleRepository.findAllBySurveyKeyAndNotificationStageIn(surveyKey, ADMIN_STAGES) + .forEach(schedule -> schedules.put(schedule.getNotificationStage(), schedule)); + + int rewardPoint = schedules.values() + .stream() + .findFirst() + .map(SurveyPushSchedule::getRewardPoint) + .orElse(0); + + return new AdminSurveyPushScheduleRes( + surveyKey, + rewardPoint, + AdminSurveyPushScheduleStageRes.from(schedules.get(SurveyNotificationStage.STARTED)), + AdminSurveyPushScheduleStageRes.from(schedules.get(SurveyNotificationStage.D_MINUS_3)), + AdminSurveyPushScheduleStageRes.from(schedules.get(SurveyNotificationStage.DEADLINE)) + ); + } + + @Transactional + public AdminSurveyPushScheduleRes cancelSchedules(String surveyKey) { + validateSurveyKey(surveyKey); + + LocalDateTime now = LocalDateTime.now(clock); + surveyPushScheduleRepository.findAllBySurveyKeyOrderByNotificationStageAscSurveyPushScheduleIdAsc(surveyKey) + .stream() + .filter(SurveyPushSchedule::isPending) + .forEach(schedule -> schedule.cancel(now)); + + return getAdminSchedules(surveyKey); + } + + @Transactional + public SurveyReminderRes remindAfterLater( + String surveyKey, + Long userId + ) { + validateSurveyKey(surveyKey); + if (userId == null || userId <= 0) { + throw new GlobalException(INVALID_INPUT); + } + + SurveyPushSchedule deadline = surveyPushScheduleRepository + .findBySurveyKeyAndNotificationStage(surveyKey, SurveyNotificationStage.DEADLINE) + .orElseThrow(() -> new GlobalException(INVALID_INPUT)); + + LocalDateTime now = LocalDateTime.now(clock); + LocalDateTime remindAt = now.plusDays(1); + String idempotencyKey = idempotencyKey( + surveyKey, + SurveyNotificationStage.REMIND_AFTER_LATER, + userId + ); + + SurveyReminderSuppressedBy suppressedBy = suppressionForReminder(surveyKey, remindAt, deadline.getScheduledAt()); + if (!SurveyReminderSuppressedBy.NONE.equals(suppressedBy)) { + cancelPendingReminderIfExists(idempotencyKey, now); + return new SurveyReminderRes(surveyKey, false, null, suppressedBy); + } + + return surveyPushScheduleRepository.findByIdempotencyKey(idempotencyKey) + .map(existing -> updateExistingReminder(surveyKey, remindAt, deadline.getRewardPoint(), existing)) + .orElseGet(() -> createReminder(surveyKey, userId, remindAt, deadline.getRewardPoint(), idempotencyKey)); + } + + private void upsertSchedule( + String surveyKey, + SurveyNotificationStage stage, + Long targetUserId, + LocalDateTime scheduledAt, + int rewardPoint + ) { + String idempotencyKey = idempotencyKey(surveyKey, stage, targetUserId); + surveyPushScheduleRepository.findByIdempotencyKey(idempotencyKey) + .ifPresentOrElse( + schedule -> schedule.updatePendingSchedule(scheduledAt, rewardPoint), + () -> surveyPushScheduleRepository.save(new SurveyPushSchedule( + surveyKey, + stage, + targetUserId, + scheduledAt, + rewardPoint, + idempotencyKey, + LocalDateTime.now(clock) + )) + ); + } + + private SurveyReminderRes updateExistingReminder( + String surveyKey, + LocalDateTime remindAt, + int rewardPoint, + SurveyPushSchedule existing + ) { + if (!existing.isPending()) { + return new SurveyReminderRes( + surveyKey, + false, + null, + SurveyReminderSuppressedBy.ALREADY_PROCESSED + ); + } + + existing.updatePendingSchedule(remindAt, rewardPoint); + return new SurveyReminderRes( + surveyKey, + true, + existing.getScheduledAt(), + SurveyReminderSuppressedBy.NONE + ); + } + + private SurveyReminderRes createReminder( + String surveyKey, + Long userId, + LocalDateTime remindAt, + int rewardPoint, + String idempotencyKey + ) { + // 현재 서버에는 설문 참여 완료 상태를 확인할 도메인이 없어 미참여 조건은 후속 설문 기능 연동이 필요하다. + SurveyPushSchedule saved = surveyPushScheduleRepository.save(new SurveyPushSchedule( + surveyKey, + SurveyNotificationStage.REMIND_AFTER_LATER, + userId, + remindAt, + rewardPoint, + idempotencyKey, + LocalDateTime.now(clock) + )); + + return new SurveyReminderRes( + surveyKey, + true, + saved.getScheduledAt(), + SurveyReminderSuppressedBy.NONE + ); + } + + private void cancelPendingReminderIfExists( + String idempotencyKey, + LocalDateTime now + ) { + surveyPushScheduleRepository.findByIdempotencyKey(idempotencyKey) + .filter(SurveyPushSchedule::isPending) + .ifPresent(schedule -> schedule.cancel(now)); + } + + private SurveyReminderSuppressedBy suppressionForReminder( + String surveyKey, + LocalDateTime remindAt, + LocalDateTime deadlineAt + ) { + if (remindAt.isAfter(deadlineAt)) { + return SurveyReminderSuppressedBy.EXPIRED; + } + + LocalDate remindDate = remindAt.toLocalDate(); + if (remindDate.equals(deadlineAt.toLocalDate())) { + return SurveyReminderSuppressedBy.DEADLINE; + } + + return surveyPushScheduleRepository + .findBySurveyKeyAndNotificationStage(surveyKey, SurveyNotificationStage.D_MINUS_3) + .map(SurveyPushSchedule::getScheduledAt) + .map(LocalDateTime::toLocalDate) + .filter(remindDate::equals) + .map(ignored -> SurveyReminderSuppressedBy.D3) + .orElse(SurveyReminderSuppressedBy.NONE); + } + + private void validateAdminRequest(AdminSurveyPushScheduleReq request) { + if (request == null + || request.startNotificationAt() == null + || request.deadlineNotificationAt() == null + || request.rewardPoint() == null + || request.rewardPoint() < 0) { + throw new GlobalException(INVALID_INPUT); + } + + if (!request.startNotificationAt().isBefore(request.deadlineNotificationAt())) { + throw new GlobalException(INVALID_INPUT); + } + } + + private void validateSurveyKey(String surveyKey) { + if (surveyKey == null || surveyKey.isBlank() || surveyKey.length() > MAX_SURVEY_KEY_LENGTH) { + throw new GlobalException(INVALID_INPUT); + } + } + + public static String idempotencyKey( + String surveyKey, + SurveyNotificationStage stage, + Long targetUserId + ) { + if (SurveyNotificationStage.REMIND_AFTER_LATER.equals(stage)) { + return "survey:" + surveyKey + ":" + stage.name() + ":" + targetUserId; + } + return "survey:" + surveyKey + ":" + stage.name(); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorker.java b/src/main/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorker.java new file mode 100644 index 00000000..b5ec71b9 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorker.java @@ -0,0 +1,203 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.dto.request.PushDispatchCommand; +import devkor.com.teamcback.domain.notification.entity.SurveyPushSchedule; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.NotificationType; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushEventType; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import devkor.com.teamcback.domain.notification.entity.type.SurveyNotificationStage; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.domain.notification.repository.SurveyPushScheduleRepository; +import devkor.com.teamcback.domain.notification.template.DomainPushContentFactory; +import devkor.com.teamcback.domain.notification.template.PushContent; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import java.time.Clock; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SurveyPushScheduleWorker { + + private static final int BATCH_SIZE = 50; + private static final Long SYSTEM_CREATED_BY = 0L; + private static final String ALL_TARGET_VALUE = "ALL"; + + private final SurveyPushScheduleRepository surveyPushScheduleRepository; + private final PushInstallationRepository pushInstallationRepository; + private final PushDispatchService pushDispatchService; + private final PushEventFlagService pushEventFlagService; + private final Clock clock; + + @Scheduled(fixedDelayString = "${push.survey.poll-interval-ms:60000}") + @Transactional + public void processDueSchedules() { + processDueSchedulesOnce(); + } + + public int processDueSchedulesOnce() { + if (!pushEventFlagService.isEnabled(PushEventType.SURVEY)) { + return 0; + } + + LocalDateTime now = LocalDateTime.now(clock); + List schedules = surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked( + now, + BATCH_SIZE + ); + + schedules.forEach(schedule -> processSchedule(schedule, now)); + return schedules.size(); + } + + private void processSchedule( + SurveyPushSchedule schedule, + LocalDateTime now + ) { + if (SurveyNotificationStage.REMIND_AFTER_LATER.equals(schedule.getNotificationStage()) + && cancelReminderByLatestPriority(schedule, now)) { + return; + } + + List activeTargetVariants = activeTargetVariants(schedule); + if (activeTargetVariants.isEmpty()) { + schedule.skip(now); + return; + } + + try { + activeTargetVariants.forEach(appVariant -> pushDispatchService.enqueue(command(schedule, appVariant))); + schedule.complete(now); + } catch (GlobalException e) { + log.warn( + "Survey push schedule processing failed. scheduleId={}, stage={}, resultCode={}", + schedule.getSurveyPushScheduleId(), + schedule.getNotificationStage(), + e.getResultCode() + ); + } catch (RuntimeException e) { + log.warn( + "Unexpected survey push schedule processing failure. scheduleId={}, stage={}, exception={}", + schedule.getSurveyPushScheduleId(), + schedule.getNotificationStage(), + e.getClass().getSimpleName() + ); + } + } + + private boolean cancelReminderByLatestPriority( + SurveyPushSchedule schedule, + LocalDateTime now + ) { + // 현재 서버에는 설문 참여 완료 상태를 확인할 도메인이 없어 미참여 조건은 후속 설문 기능 연동이 필요하다. + LocalDate executionDate = schedule.getScheduledAt().toLocalDate(); + + SurveyPushSchedule deadline = surveyPushScheduleRepository + .findBySurveyKeyAndNotificationStage(schedule.getSurveyKey(), SurveyNotificationStage.DEADLINE) + .orElse(null); + if (deadline == null) { + schedule.cancel(now); + return true; + } + + if (executionDate.equals(deadline.getScheduledAt().toLocalDate()) + || now.isAfter(deadline.getScheduledAt())) { + schedule.cancel(now); + return true; + } + + return surveyPushScheduleRepository + .findBySurveyKeyAndNotificationStage(schedule.getSurveyKey(), SurveyNotificationStage.D_MINUS_3) + .map(SurveyPushSchedule::getScheduledAt) + .map(LocalDateTime::toLocalDate) + .filter(executionDate::equals) + .map(ignored -> { + schedule.cancel(now); + return true; + }) + .orElse(false); + } + + private PushDispatchCommand command( + SurveyPushSchedule schedule, + AppVariant appVariant + ) { + PushContent content = content(schedule); + + return new PushDispatchCommand( + NotificationType.GENERAL, + PushMode.ACTUAL, + appVariant, + targetType(schedule), + targetValue(schedule), + content.title(), + content.body(), + PushActionType.HOME, + Map.of(), + "%s:%s".formatted(schedule.getIdempotencyKey(), appVariant.name().toLowerCase()), + SYSTEM_CREATED_BY + ); + } + + private PushContent content(SurveyPushSchedule schedule) { + return switch (schedule.getNotificationStage()) { + case STARTED -> DomainPushContentFactory.surveyStarted(); + case D_MINUS_3 -> DomainPushContentFactory.surveyDMinus3(); + case DEADLINE -> DomainPushContentFactory.surveyDeadline(schedule.getRewardPoint()); + case REMIND_AFTER_LATER -> DomainPushContentFactory.surveyRemindAfterLater(schedule.getRewardPoint()); + }; + } + + private PushTargetType targetType(SurveyPushSchedule schedule) { + if (SurveyNotificationStage.REMIND_AFTER_LATER.equals(schedule.getNotificationStage())) { + return PushTargetType.USER; + } + return PushTargetType.ALL; + } + + private String targetValue(SurveyPushSchedule schedule) { + if (SurveyNotificationStage.REMIND_AFTER_LATER.equals(schedule.getNotificationStage())) { + return String.valueOf(schedule.getTargetUserId()); + } + return ALL_TARGET_VALUE; + } + + private List activeTargetVariants(SurveyPushSchedule schedule) { + return targetAppVariants().stream() + .filter(appVariant -> hasActiveTarget(schedule, appVariant)) + .toList(); + } + + private boolean hasActiveTarget( + SurveyPushSchedule schedule, + AppVariant appVariant + ) { + if (SurveyNotificationStage.REMIND_AFTER_LATER.equals(schedule.getNotificationStage())) { + return schedule.getTargetUserId() != null + && pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue( + schedule.getTargetUserId(), + appVariant + ); + } + + return pushInstallationRepository.existsByAppVariantAndActiveTrue(appVariant); + } + + private List targetAppVariants() { + List configuredVariants = pushEventFlagService.getTargetAppVariants(); + return configuredVariants == null || configuredVariants.isEmpty() + ? List.of(AppVariant.PRODUCTION) + : configuredVariants; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactory.java b/src/main/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactory.java new file mode 100644 index 00000000..a3041462 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactory.java @@ -0,0 +1,95 @@ +package devkor.com.teamcback.domain.notification.template; + +public final class DomainPushContentFactory { + + private static final String VACANT_TITLE = + "기다리던 자리가 생겼어요!"; + + private static final String REPORT_RESOLVED_TITLE = + "신고 처리 결과를 확인해주세요."; + + private static final String REPORT_RESOLVED_BODY = + "접수한 신고의 처리가 완료되었습니다. 고대로에서 결과를 확인해주세요."; + + private static final String CHARACTER_UNLOCKED_TITLE = + "새 캐릭터가 기다리고 있어요!"; + + private DomainPushContentFactory() { + } + + public static PushContent placeBecameVacant( + String buildingName, + String placeName + ) { + String location = joinNonBlank(buildingName, placeName); + + return new PushContent( + VACANT_TITLE, + location + "이 한산해요. 방문하기 전 현황을 확인해보세요." + ); + } + + public static PushContent reportResolved() { + return new PushContent( + REPORT_RESOLVED_TITLE, + REPORT_RESOLVED_BODY + ); + } + + public static PushContent characterUnlocked(String characterName) { + String name = isBlank(characterName) + ? "새로운 캐릭터" + : characterName.trim(); + + return new PushContent( + CHARACTER_UNLOCKED_TITLE, + name + "을 만나러 가볼까요?" + ); + } + + public static PushContent surveyStarted() { + return new PushContent( + "고대로를 함께 만들어주세요!", + "잠깐의 설문으로 고대로를 더 편리하게 만들어주세요." + ); + } + + public static PushContent surveyDMinus3() { + return new PushContent( + "단 3초! 고대로의 개선을 위해 도와주세요", + "잠깐의 설문으로 고대로를 더 편리하게 만들어주세요." + ); + } + + public static PushContent surveyDeadline(int rewardPoint) { + return new PushContent( + "설문이 오늘 마감돼요!", + "설문에 참여하면 " + rewardPoint + " 포인트를 받을 수 있어요.(5초 소요)" + ); + } + + public static PushContent surveyRemindAfterLater(int rewardPoint) { + return new PushContent( + "잠깐, 설문을 잊지 않으셨나요?", + "지금 투표에 참여하고 " + rewardPoint + "포인트를 받아보세요.(5초 소요)" + ); + } + + private static String joinNonBlank( + String first, + String second + ) { + String firstValue = isBlank(first) ? "" : first.trim(); + String secondValue = isBlank(second) ? "" : second.trim(); + + String result = (firstValue + " " + secondValue).trim(); + + return result.isBlank() + ? "즐겨찾기한 공간" + : result; + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/template/PushContent.java b/src/main/java/devkor/com/teamcback/domain/notification/template/PushContent.java new file mode 100644 index 00000000..dd8a3747 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/template/PushContent.java @@ -0,0 +1,7 @@ +package devkor.com.teamcback.domain.notification.template; + +public record PushContent( + String title, + String body +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/notification/validation/PushActionValidator.java b/src/main/java/devkor/com/teamcback/domain/notification/validation/PushActionValidator.java new file mode 100644 index 00000000..70c78479 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/notification/validation/PushActionValidator.java @@ -0,0 +1,110 @@ +package devkor.com.teamcback.domain.notification.validation; + +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; +import org.springframework.stereotype.Component; + +import static devkor.com.teamcback.global.response.ResultCode.INVALID_INPUT; + +@Component +public class PushActionValidator { + + private static final Set ACTIONS_WITHOUT_PARAMS = Set.of( + PushActionType.HOME, + PushActionType.NOTICE, + PushActionType.MY_PAGE, + PushActionType.CHARACTER_STORE, + PushActionType.TEST + ); + + public Map validateAndNormalize( + PushActionType actionType, + PushMode mode, + AppVariant appVariant, + Map params + ) { + if (actionType == null || mode == null || appVariant == null) { + throw new GlobalException(INVALID_INPUT); + } + + Map safeParams = params == null ? Collections.emptyMap() : params; + + if (PushActionType.TEST.equals(actionType) && ( + AppVariant.PRODUCTION.equals(appVariant) + || PushMode.ACTUAL.equals(mode) + )) { + throw new GlobalException(INVALID_INPUT); + } + + if (ACTIONS_WITHOUT_PARAMS.contains(actionType)) { + validateNoParams(safeParams); + return Collections.emptyMap(); + } + + return switch (actionType) { + case BUS_STOP -> validateSinglePositiveLong(safeParams, "stopId"); + case BUILDING_DETAIL -> validateSinglePositiveLong(safeParams, "buildingId"); + case PLACE_DETAIL -> validateSinglePositiveLong(safeParams, "placeId"); + default -> throw new GlobalException(INVALID_INPUT); + }; + } + + private void validateNoParams(Map params) { + if (!params.isEmpty()) { + throw new GlobalException(INVALID_INPUT); + } + } + + private Map validateSinglePositiveLong( + Map params, + String requiredKey + ) { + if (params.size() != 1 || !params.containsKey(requiredKey)) { + throw new GlobalException(INVALID_INPUT); + } + + Long value = parsePositiveLong(params.get(requiredKey)); + + Map normalized = new LinkedHashMap<>(); + normalized.put(requiredKey, value); + return normalized; + } + + private Long parsePositiveLong(Object value) { + Long parsed; + + if (value instanceof Integer integerValue) { + parsed = integerValue.longValue(); + } else if (value instanceof Long longValue) { + parsed = longValue; + } else if (value instanceof String stringValue) { + parsed = parseString(stringValue); + } else { + throw new GlobalException(INVALID_INPUT); + } + + if (parsed <= 0) { + throw new GlobalException(INVALID_INPUT); + } + + return parsed; + } + + private Long parseString(String value) { + if (value == null || value.isBlank()) { + throw new GlobalException(INVALID_INPUT); + } + + try { + return Long.parseLong(value); + } catch (NumberFormatException e) { + throw new GlobalException(INVALID_INPUT); + } + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/place/entity/PlaceType.java b/src/main/java/devkor/com/teamcback/domain/place/entity/PlaceType.java index cf15e2bc..26e2b787 100644 --- a/src/main/java/devkor/com/teamcback/domain/place/entity/PlaceType.java +++ b/src/main/java/devkor/com/teamcback/domain/place/entity/PlaceType.java @@ -41,7 +41,8 @@ public enum PlaceType { CAFE_TEMP("임시용 외부 카페", new String[] {}), CONV_TEMP("임시용 외부 편의점", new String[] {}), CAFT_TEMP("임시용 외부 식당", new String[] {}), - REUSABLE_CUP_RETURN("다회용컵 반납함", new String[] {"다회용컵반납함", "리필로드"}),; + REUSABLE_CUP_RETURN("다회용컵 반납함", new String[] {"다회용컵반납함", "리필로드"}), + PHARMACY("약국", new String[]{}),; private final String name; private final String[] nickname; diff --git a/src/main/java/devkor/com/teamcback/domain/report/event/ReportResolvedEvent.java b/src/main/java/devkor/com/teamcback/domain/report/event/ReportResolvedEvent.java new file mode 100644 index 00000000..025c971c --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/report/event/ReportResolvedEvent.java @@ -0,0 +1,10 @@ +package devkor.com.teamcback.domain.report.event; + +import devkor.com.teamcback.domain.report.entity.ReportStatus; + +public record ReportResolvedEvent( + Long reportId, + Long reporterUserId, + ReportStatus finalStatus +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/report/service/ReportService.java b/src/main/java/devkor/com/teamcback/domain/report/service/ReportService.java index ad81dfbd..60c5d81c 100644 --- a/src/main/java/devkor/com/teamcback/domain/report/service/ReportService.java +++ b/src/main/java/devkor/com/teamcback/domain/report/service/ReportService.java @@ -6,6 +6,7 @@ import devkor.com.teamcback.domain.report.entity.Report; import devkor.com.teamcback.domain.report.entity.ReportStatus; import devkor.com.teamcback.domain.report.entity.TargetType; +import devkor.com.teamcback.domain.report.event.ReportResolvedEvent; import devkor.com.teamcback.domain.report.repository.ReportRepository; import devkor.com.teamcback.domain.review.entity.Review; import devkor.com.teamcback.domain.review.repository.ReviewRepository; @@ -14,6 +15,7 @@ import devkor.com.teamcback.global.exception.exception.GlobalException; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -31,6 +33,7 @@ public class ReportService { private final ReportRepository reportRepository; private final ReviewRepository reviewRepository; private final UserRepository userRepository; + private final ApplicationEventPublisher eventPublisher; /** * 리뷰에 대한 신고 작성 @@ -98,6 +101,7 @@ public GetReportListRes getReportList(ReportStatus status) { public UpdateReportStatusRes updateReportStatus(Long reportId, UpdateReportStatusReq req) { // 신고 Report report = findReport(reportId); + ReportStatus previousStatus = report.getStatus(); // 신고 상태 수정 report.setStatus(req.getStatus()); @@ -115,9 +119,31 @@ public UpdateReportStatusRes updateReportStatus(Long reportId, UpdateReportStatu } } + if (shouldPublishReportResolved(previousStatus, req.getStatus())) { + eventPublisher.publishEvent(new ReportResolvedEvent( + report.getId(), + report.getReporter() == null ? null : report.getReporter().getUserId(), + req.getStatus() + )); + } + return new UpdateReportStatusRes(); } + private boolean shouldPublishReportResolved( + ReportStatus previousStatus, + ReportStatus newStatus + ) { + return PENDING.equals(previousStatus) + && isFinalStatus(newStatus); + } + + private boolean isFinalStatus(ReportStatus status) { + return RESOLVED.equals(status) + || REJECTED.equals(status) + || EXPIRED.equals(status); + } + /** * 신고 유효일 체크하고 상태 수정 */ diff --git a/src/main/java/devkor/com/teamcback/domain/review/dto/response/SearchPlaceReviewRes.java b/src/main/java/devkor/com/teamcback/domain/review/dto/response/SearchPlaceReviewRes.java index b9f71187..a68756b0 100644 --- a/src/main/java/devkor/com/teamcback/domain/review/dto/response/SearchPlaceReviewRes.java +++ b/src/main/java/devkor/com/teamcback/domain/review/dto/response/SearchPlaceReviewRes.java @@ -14,6 +14,12 @@ public class SearchPlaceReviewRes { @Schema(description = "리뷰 사용자 id") private Long userId; + @Schema(description = "리뷰 작성자 닉네임") + private String username; + + @Schema(description = "리뷰 작성자 프로필 이미지 URL") + private String profileImageUrl; + @Schema(description = "리뷰 id") private Long reviewId; @@ -29,8 +35,14 @@ public class SearchPlaceReviewRes { @Schema(description = "리뷰별 사진 목록") private List reviewImageRes; - public SearchPlaceReviewRes(Review review, List reviewImageRes) { + public SearchPlaceReviewRes( + Review review, + List reviewImageRes, + String profileImageUrl + ) { this.userId = review.getUser() != null ? review.getUser().getUserId() : null; + this.username = review.getUser() != null ? review.getUser().getUsername() : null; + this.profileImageUrl = profileImageUrl; this.reviewId = review.getId(); this.isRevisit = review.isRevisit(); this.comment = review.getComment(); diff --git a/src/main/java/devkor/com/teamcback/domain/review/service/ReviewService.java b/src/main/java/devkor/com/teamcback/domain/review/service/ReviewService.java index 19e5df9a..610a71d0 100644 --- a/src/main/java/devkor/com/teamcback/domain/review/service/ReviewService.java +++ b/src/main/java/devkor/com/teamcback/domain/review/service/ReviewService.java @@ -3,6 +3,7 @@ import devkor.com.teamcback.domain.common.entity.File; import devkor.com.teamcback.domain.common.repository.FileRepository; import devkor.com.teamcback.domain.common.util.FileUtil; +import devkor.com.teamcback.domain.character.repository.CharacterRepository; import devkor.com.teamcback.domain.place.entity.Place; import devkor.com.teamcback.domain.place.entity.PlaceType; import devkor.com.teamcback.domain.place.repository.PlaceRepository; @@ -43,6 +44,7 @@ public class ReviewService { private final PlaceReviewTagMapRepository placeReviewTagMapRepository; private final FileRepository fileRepository; private final UserRepository userRepository; + private final CharacterRepository characterRepository; private final FileUtil fileUtil; /** @@ -88,6 +90,17 @@ public GetReviewPlaceDetailRes getReviewPlaceDetail(Long placeId) { // 리뷰 최신순 조회 List reviewList = reviewRepository.findAllByPlaceAndIsReportedOrderByCreatedAtDesc(place, false); + List equippedCharacterIds = reviewList.stream() + .map(Review::getUser) + .filter(user -> user != null && user.getEquippedCharacterId() != null) + .map(User::getEquippedCharacterId) + .distinct() + .toList(); + Map equippedCharacterImages = new HashMap<>(); + characterRepository.findAllById(equippedCharacterIds).forEach(character -> + equippedCharacterImages.put(character.getCharacterId(), character.getImageUrl()) + ); + // 리뷰별 이미지 조회(썸네일) List reviewImageList = new ArrayList<>(); @@ -98,7 +111,13 @@ public GetReviewPlaceDetailRes getReviewPlaceDetail(Long placeId) { // 썸네일 이미지 추출 List imageResList = reviewFiles.stream().map(file -> new SearchReviewImageRes(file.getId(), file.getThumbSavedName())).toList(); - reviewImageList.add(new SearchPlaceReviewRes(review, imageResList)); + Long equippedCharacterId = review.getUser() != null + ? review.getUser().getEquippedCharacterId() + : null; + String profileImageUrl = equippedCharacterId != null + ? equippedCharacterImages.get(equippedCharacterId) + : null; + reviewImageList.add(new SearchPlaceReviewRes(review, imageResList, profileImageUrl)); } // 리뷰 전체 이미지 조회(원본 - 10장까지) @@ -278,7 +297,8 @@ private Review findReviewById(Long reviewId) { private void checkReviewPlaceType(Place place) { // 식당, 카페만 조회 가능하도록 제한 if(place.getType() != PlaceType.CAFETERIA && place.getType() != PlaceType.CAFE - && place.getType() != PlaceType.CAFE_TEMP && place.getType() != PlaceType.CAFT_TEMP && place.getType() != PlaceType.CONV_TEMP) { + && place.getType() != PlaceType.CAFE_TEMP && place.getType() != PlaceType.CAFT_TEMP + && place.getType() != PlaceType.CONV_TEMP && place.getType() != PlaceType.PHARMACY) { throw new GlobalException(ResultCode.NOT_SUPPORTED_PLACE_TYPE); } } diff --git a/src/main/java/devkor/com/teamcback/domain/suggestion/entity/SuggestionType.java b/src/main/java/devkor/com/teamcback/domain/suggestion/entity/SuggestionType.java index e28ae98f..a4520272 100644 --- a/src/main/java/devkor/com/teamcback/domain/suggestion/entity/SuggestionType.java +++ b/src/main/java/devkor/com/teamcback/domain/suggestion/entity/SuggestionType.java @@ -16,6 +16,7 @@ public enum SuggestionType { FEATURE_SUGGESTION("추천 기능"), INCONVENIENCE("불편 사항"), QUESTION("질의 사항"), - OTHER("기타"); + OTHER("기타"), + POPUP("팝업 추가 답변"); private final String type; } diff --git a/src/main/java/devkor/com/teamcback/domain/usagesurvey/controller/UsageSurveyController.java b/src/main/java/devkor/com/teamcback/domain/usagesurvey/controller/UsageSurveyController.java new file mode 100644 index 00000000..401b50c4 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/usagesurvey/controller/UsageSurveyController.java @@ -0,0 +1,52 @@ +package devkor.com.teamcback.domain.usagesurvey.controller; + +import devkor.com.teamcback.domain.usagesurvey.dto.request.RecordUsageSurveyDismissalReq; +import devkor.com.teamcback.domain.usagesurvey.dto.request.SubmitUsageSurveyResponseReq; +import devkor.com.teamcback.domain.usagesurvey.dto.response.SubmitUsageSurveyResponseRes; +import devkor.com.teamcback.domain.usagesurvey.dto.response.UsageSurveyStatusRes; +import devkor.com.teamcback.domain.usagesurvey.service.UsageSurveyService; +import devkor.com.teamcback.global.response.CommonResponse; +import devkor.com.teamcback.global.security.UserDetailsImpl; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/usage-surveys") +public class UsageSurveyController { + private final UsageSurveyService usageSurveyService; + + @GetMapping("/status") + public CommonResponse getStatus( + @AuthenticationPrincipal UserDetailsImpl userDetail + ) { + return CommonResponse.success( + usageSurveyService.getStatus(userDetail.getUser().getUserId()) + ); + } + + @PostMapping("/responses") + public CommonResponse submitResponse( + @AuthenticationPrincipal UserDetailsImpl userDetail, + @RequestBody SubmitUsageSurveyResponseReq req + ) { + return CommonResponse.success(usageSurveyService.submitResponse( + userDetail.getUser().getUserId(), + req + )); + } + + @PostMapping("/dismissals") + public CommonResponse recordDismissal( + @AuthenticationPrincipal UserDetailsImpl userDetail, + @RequestBody RecordUsageSurveyDismissalReq req + ) { + usageSurveyService.recordDismissal(userDetail.getUser().getUserId(), req); + return CommonResponse.success(null); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/usagesurvey/dto/request/RecordUsageSurveyDismissalReq.java b/src/main/java/devkor/com/teamcback/domain/usagesurvey/dto/request/RecordUsageSurveyDismissalReq.java new file mode 100644 index 00000000..a22561d2 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/usagesurvey/dto/request/RecordUsageSurveyDismissalReq.java @@ -0,0 +1,10 @@ +package devkor.com.teamcback.domain.usagesurvey.dto.request; + +import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyDismissReason; +import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyQuestion; + +public record RecordUsageSurveyDismissalReq( + UsageSurveyQuestion questionKey, + UsageSurveyDismissReason reason +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/usagesurvey/dto/request/SubmitUsageSurveyResponseReq.java b/src/main/java/devkor/com/teamcback/domain/usagesurvey/dto/request/SubmitUsageSurveyResponseReq.java new file mode 100644 index 00000000..791ea24a --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/usagesurvey/dto/request/SubmitUsageSurveyResponseReq.java @@ -0,0 +1,9 @@ +package devkor.com.teamcback.domain.usagesurvey.dto.request; + +import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyQuestion; + +public record SubmitUsageSurveyResponseReq( + UsageSurveyQuestion questionKey, + String optionKey +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/usagesurvey/dto/response/SubmitUsageSurveyResponseRes.java b/src/main/java/devkor/com/teamcback/domain/usagesurvey/dto/response/SubmitUsageSurveyResponseRes.java new file mode 100644 index 00000000..102a3e54 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/usagesurvey/dto/response/SubmitUsageSurveyResponseRes.java @@ -0,0 +1,30 @@ +package devkor.com.teamcback.domain.usagesurvey.dto.response; + +import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyQuestion; +import devkor.com.teamcback.global.response.ScoreUpdateResponse; +import lombok.Getter; +import lombok.Setter; + +@Getter +public class SubmitUsageSurveyResponseRes implements ScoreUpdateResponse { + private final UsageSurveyQuestion questionKey; + private final String optionKey; + private final int rewardPoint; + + @Setter + private boolean levelUp; + @Setter + private Long currentScore; + @Setter + private boolean scoreGained; + + public SubmitUsageSurveyResponseRes( + UsageSurveyQuestion questionKey, + String optionKey, + int rewardPoint + ) { + this.questionKey = questionKey; + this.optionKey = optionKey; + this.rewardPoint = rewardPoint; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/usagesurvey/dto/response/UsageSurveyStatusRes.java b/src/main/java/devkor/com/teamcback/domain/usagesurvey/dto/response/UsageSurveyStatusRes.java new file mode 100644 index 00000000..de45ca24 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/usagesurvey/dto/response/UsageSurveyStatusRes.java @@ -0,0 +1,10 @@ +package devkor.com.teamcback.domain.usagesurvey.dto.response; + +import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyQuestion; +import java.util.List; + +public record UsageSurveyStatusRes( + List answeredQuestionKeys, + int rewardPoint +) { +} diff --git a/src/main/java/devkor/com/teamcback/domain/usagesurvey/entity/UsageSurveyDismissReason.java b/src/main/java/devkor/com/teamcback/domain/usagesurvey/entity/UsageSurveyDismissReason.java new file mode 100644 index 00000000..6951e557 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/usagesurvey/entity/UsageSurveyDismissReason.java @@ -0,0 +1,6 @@ +package devkor.com.teamcback.domain.usagesurvey.entity; + +public enum UsageSurveyDismissReason { + LATER, + AUTO_DISMISS +} diff --git a/src/main/java/devkor/com/teamcback/domain/usagesurvey/entity/UsageSurveyDismissal.java b/src/main/java/devkor/com/teamcback/domain/usagesurvey/entity/UsageSurveyDismissal.java new file mode 100644 index 00000000..ddb56b6a --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/usagesurvey/entity/UsageSurveyDismissal.java @@ -0,0 +1,49 @@ +package devkor.com.teamcback.domain.usagesurvey.entity; + +import devkor.com.teamcback.domain.common.entity.BaseEntity; +import devkor.com.teamcback.domain.user.entity.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +@Table(name = "tb_usage_survey_dismissal") +public class UsageSurveyDismissal extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(name = "question_key", nullable = false, length = 40) + private UsageSurveyQuestion questionKey; + + @Enumerated(EnumType.STRING) + @Column(name = "dismiss_reason", nullable = false, length = 30) + private UsageSurveyDismissReason dismissReason; + + public UsageSurveyDismissal( + User user, + UsageSurveyQuestion questionKey, + UsageSurveyDismissReason dismissReason + ) { + this.user = user; + this.questionKey = questionKey; + this.dismissReason = dismissReason; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/usagesurvey/entity/UsageSurveyQuestion.java b/src/main/java/devkor/com/teamcback/domain/usagesurvey/entity/UsageSurveyQuestion.java new file mode 100644 index 00000000..e3b15b13 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/usagesurvey/entity/UsageSurveyQuestion.java @@ -0,0 +1,8 @@ +package devkor.com.teamcback.domain.usagesurvey.entity; + +public enum UsageSurveyQuestion { + INSTALL_REASON, + RECENT_USE_REASON, + DESIRED_FEATURE, + DISAPPOINTMENT +} diff --git a/src/main/java/devkor/com/teamcback/domain/usagesurvey/entity/UsageSurveyResponse.java b/src/main/java/devkor/com/teamcback/domain/usagesurvey/entity/UsageSurveyResponse.java new file mode 100644 index 00000000..826694c7 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/usagesurvey/entity/UsageSurveyResponse.java @@ -0,0 +1,55 @@ +package devkor.com.teamcback.domain.usagesurvey.entity; + +import devkor.com.teamcback.domain.common.entity.BaseEntity; +import devkor.com.teamcback.domain.user.entity.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor +@Table( + name = "tb_usage_survey_response", + uniqueConstraints = @UniqueConstraint( + name = "uk_usage_survey_response_user_question", + columnNames = {"user_id", "question_key"} + ) +) +public class UsageSurveyResponse extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(name = "question_key", nullable = false, length = 40) + private UsageSurveyQuestion questionKey; + + @Column(name = "option_key", nullable = false, length = 60) + private String optionKey; + + public UsageSurveyResponse( + User user, + UsageSurveyQuestion questionKey, + String optionKey + ) { + this.user = user; + this.questionKey = questionKey; + this.optionKey = optionKey; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/usagesurvey/repository/UsageSurveyDismissalRepository.java b/src/main/java/devkor/com/teamcback/domain/usagesurvey/repository/UsageSurveyDismissalRepository.java new file mode 100644 index 00000000..2fb11415 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/usagesurvey/repository/UsageSurveyDismissalRepository.java @@ -0,0 +1,7 @@ +package devkor.com.teamcback.domain.usagesurvey.repository; + +import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyDismissal; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UsageSurveyDismissalRepository extends JpaRepository { +} diff --git a/src/main/java/devkor/com/teamcback/domain/usagesurvey/repository/UsageSurveyResponseRepository.java b/src/main/java/devkor/com/teamcback/domain/usagesurvey/repository/UsageSurveyResponseRepository.java new file mode 100644 index 00000000..9dd9ceaa --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/usagesurvey/repository/UsageSurveyResponseRepository.java @@ -0,0 +1,12 @@ +package devkor.com.teamcback.domain.usagesurvey.repository; + +import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyQuestion; +import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyResponse; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UsageSurveyResponseRepository extends JpaRepository { + boolean existsByUserUserIdAndQuestionKey(Long userId, UsageSurveyQuestion questionKey); + + List findAllByUserUserId(Long userId); +} diff --git a/src/main/java/devkor/com/teamcback/domain/usagesurvey/service/UsageSurveyService.java b/src/main/java/devkor/com/teamcback/domain/usagesurvey/service/UsageSurveyService.java new file mode 100644 index 00000000..e9c72bba --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/usagesurvey/service/UsageSurveyService.java @@ -0,0 +1,137 @@ +package devkor.com.teamcback.domain.usagesurvey.service; + +import static devkor.com.teamcback.global.response.ResultCode.ALREADY_ANSWERED_USAGE_SURVEY; +import static devkor.com.teamcback.global.response.ResultCode.INVALID_USAGE_SURVEY_OPTION; +import static devkor.com.teamcback.global.response.ResultCode.NOT_FOUND_USER; + +import devkor.com.teamcback.domain.usagesurvey.dto.request.RecordUsageSurveyDismissalReq; +import devkor.com.teamcback.domain.usagesurvey.dto.request.SubmitUsageSurveyResponseReq; +import devkor.com.teamcback.domain.usagesurvey.dto.response.SubmitUsageSurveyResponseRes; +import devkor.com.teamcback.domain.usagesurvey.dto.response.UsageSurveyStatusRes; +import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyDismissReason; +import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyDismissal; +import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyQuestion; +import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyResponse; +import devkor.com.teamcback.domain.usagesurvey.repository.UsageSurveyDismissalRepository; +import devkor.com.teamcback.domain.usagesurvey.repository.UsageSurveyResponseRepository; +import devkor.com.teamcback.domain.user.entity.User; +import devkor.com.teamcback.domain.user.repository.UserRepository; +import devkor.com.teamcback.global.annotation.UpdateScore; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import java.util.List; +import java.util.Map; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class UsageSurveyService { + public static final int REWARD_POINT = 10; + + private static final Map> ALLOWED_OPTIONS = Map.of( + UsageSurveyQuestion.INSTALL_REASON, Set.of( + "COMPLEX_CAMPUS_ROUTES", + "SCHOOL_INFO", + "FACILITIES", + "LOUNGE_CROWDING", + "BUILDING_INTERIOR" + ), + UsageSurveyQuestion.RECENT_USE_REASON, Set.of( + "ROUTE", + "CROWDING", + "FACILITIES", + "MEAL_SHUTTLE", + "OTHER" + ), + UsageSurveyQuestion.DESIRED_FEATURE, Set.of( + "SCHEDULE_ROUTE", + "STUDY_ROOM_LINK", + "CUP_AVAILABILITY", + "QUIET_ALERT", + "COMMUNITY" + ), + UsageSurveyQuestion.DISAPPOINTMENT, Set.of( + "STALE_INFO", + "NAVIGATION_DIFFICULTY", + "MISSING_REVIEW_OUTLET", + "CROWD_MISMATCH", + "OTHER" + ) + ); + + private final UsageSurveyResponseRepository responseRepository; + private final UsageSurveyDismissalRepository dismissalRepository; + private final UserRepository userRepository; + + @Transactional(readOnly = true) + public UsageSurveyStatusRes getStatus(Long userId) { + findUser(userId); + List answeredQuestionKeys = responseRepository + .findAllByUserUserId(userId) + .stream() + .map(UsageSurveyResponse::getQuestionKey) + .distinct() + .toList(); + return new UsageSurveyStatusRes(answeredQuestionKeys, REWARD_POINT); + } + + @Transactional + @UpdateScore(addScore = REWARD_POINT) + public SubmitUsageSurveyResponseRes submitResponse( + Long userId, + SubmitUsageSurveyResponseReq req + ) { + validateResponse(req); + if (responseRepository.existsByUserUserIdAndQuestionKey(userId, req.questionKey())) { + throw new GlobalException(ALREADY_ANSWERED_USAGE_SURVEY); + } + + User user = findUser(userId); + responseRepository.save(new UsageSurveyResponse( + user, + req.questionKey(), + req.optionKey() + )); + return new SubmitUsageSurveyResponseRes( + req.questionKey(), + req.optionKey(), + REWARD_POINT + ); + } + + @Transactional + public void recordDismissal( + Long userId, + RecordUsageSurveyDismissalReq req + ) { + if (req == null || req.questionKey() == null) { + throw new GlobalException(INVALID_USAGE_SURVEY_OPTION); + } + User user = findUser(userId); + UsageSurveyDismissReason reason = req.reason() == null + ? UsageSurveyDismissReason.LATER + : req.reason(); + dismissalRepository.save(new UsageSurveyDismissal( + user, + req.questionKey(), + reason + )); + } + + private void validateResponse(SubmitUsageSurveyResponseReq req) { + if (req == null || req.questionKey() == null || req.optionKey() == null) { + throw new GlobalException(INVALID_USAGE_SURVEY_OPTION); + } + Set options = ALLOWED_OPTIONS.get(req.questionKey()); + if (options == null || !options.contains(req.optionKey())) { + throw new GlobalException(INVALID_USAGE_SURVEY_OPTION); + } + } + + private User findUser(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new GlobalException(NOT_FOUND_USER)); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/user/controller/UserController.java b/src/main/java/devkor/com/teamcback/domain/user/controller/UserController.java index 30af7515..0814ac83 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/controller/UserController.java +++ b/src/main/java/devkor/com/teamcback/domain/user/controller/UserController.java @@ -1,7 +1,9 @@ package devkor.com.teamcback.domain.user.controller; import devkor.com.teamcback.domain.user.dto.request.BypassLoginReq; +import devkor.com.teamcback.domain.user.dto.request.AdminLoginReq; import devkor.com.teamcback.domain.user.dto.request.LoginUserReq; +import devkor.com.teamcback.domain.user.dto.response.AdminLoginRes; import devkor.com.teamcback.domain.user.dto.response.BypassLoginRes; import devkor.com.teamcback.domain.user.dto.response.DeleteUserRes; import devkor.com.teamcback.domain.user.dto.response.GetUserInfoRes; @@ -59,6 +61,15 @@ public CommonResponse releaseLogin( return CommonResponse.success(userService.releaseLogin(loginUserReq)); } + /** + * 관리자 대시보드 소셜 로그인 + */ + @Operation(summary = "관리자 소셜 로그인", description = "Google 또는 Kakao ID Token을 검증하고 기존 ADMIN 계정에만 토큰을 발급") + @PostMapping("/login/admin") + public CommonResponse adminLogin(@RequestBody AdminLoginReq adminLoginReq) { + return CommonResponse.success(userService.adminLogin(adminLoginReq)); + } + /** * 자동 로그인 */ diff --git a/src/main/java/devkor/com/teamcback/domain/user/dto/request/AdminLoginReq.java b/src/main/java/devkor/com/teamcback/domain/user/dto/request/AdminLoginReq.java new file mode 100644 index 00000000..dd8c20e4 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/user/dto/request/AdminLoginReq.java @@ -0,0 +1,15 @@ +package devkor.com.teamcback.domain.user.dto.request; + +import devkor.com.teamcback.domain.user.entity.Provider; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; + +@Getter +@Schema(description = "관리자 소셜 로그인 요청") +public class AdminLoginReq { + @Schema(description = "관리자 소셜 로그인 제공자", allowableValues = {"GOOGLE", "KAKAO"}) + private Provider provider; + + @Schema(description = "소셜 로그인에서 발급받은 OIDC ID Token") + private String token; +} diff --git a/src/main/java/devkor/com/teamcback/domain/user/dto/response/AdminLoginRes.java b/src/main/java/devkor/com/teamcback/domain/user/dto/response/AdminLoginRes.java new file mode 100644 index 00000000..16ba6507 --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/user/dto/response/AdminLoginRes.java @@ -0,0 +1,24 @@ +package devkor.com.teamcback.domain.user.dto.response; + +import devkor.com.teamcback.domain.user.entity.Provider; +import devkor.com.teamcback.domain.user.entity.User; +import lombok.Getter; + +@Getter +public class AdminLoginRes { + private final String accessToken; + private final String refreshToken; + private final Long userId; + private final String username; + private final String email; + private final Provider provider; + + public AdminLoginRes(String accessToken, String refreshToken, User user) { + this.accessToken = accessToken; + this.refreshToken = refreshToken; + this.userId = user.getUserId(); + this.username = user.getUsername(); + this.email = user.getEmail(); + this.provider = user.getProvider(); + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/user/dto/response/GetUserInfoRes.java b/src/main/java/devkor/com/teamcback/domain/user/dto/response/GetUserInfoRes.java index 2c7ea2d1..27bddead 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/dto/response/GetUserInfoRes.java +++ b/src/main/java/devkor/com/teamcback/domain/user/dto/response/GetUserInfoRes.java @@ -21,6 +21,8 @@ public class GetUserInfoRes { private int level; @Schema(description = "score", example = "15") private Long score; + @Schema(description = "보유 포인트 (스토어 재화)", example = "15") + private Long point; @Schema(description = "remainScoreToNextLevel", example = "10") private Long remainScoreToNextLevel; @Schema(description = "percent", example = "75") @@ -38,6 +40,7 @@ public GetUserInfoRes(User user, Long categoryCount, int level, Long remainScore this.role = user.getRole(); this.level = level; this.score = user.getScore(); + this.point = user.getPoint(); this.remainScoreToNextLevel = remainScoreToNextLevel; this.percent = percent; this.isUpgraded = isUpgraded; diff --git a/src/main/java/devkor/com/teamcback/domain/user/dto/response/LoginUserRes.java b/src/main/java/devkor/com/teamcback/domain/user/dto/response/LoginUserRes.java index 5b648f3e..4ec6835e 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/dto/response/LoginUserRes.java +++ b/src/main/java/devkor/com/teamcback/domain/user/dto/response/LoginUserRes.java @@ -9,4 +9,5 @@ public class LoginUserRes { String accessToken; String refreshToken; String code; + String loginKey; } diff --git a/src/main/java/devkor/com/teamcback/domain/user/entity/Level.java b/src/main/java/devkor/com/teamcback/domain/user/entity/Level.java index 996f80cf..8fa2d4d5 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/entity/Level.java +++ b/src/main/java/devkor/com/teamcback/domain/user/entity/Level.java @@ -6,31 +6,31 @@ @Getter @AllArgsConstructor public enum Level { - LEVEL1(0, 1, ProfileImage.getUrlByLevel(1)) { // 0~4점 + LEVEL1(0, 1) { // 0~4점 @Override public Level getNextLevel() { return LEVEL2; } }, - LEVEL2(5, 2, ProfileImage.getUrlByLevel(2)) { // 5~19점 + LEVEL2(5, 2) { // 5~19점 @Override public Level getNextLevel() { return LEVEL3; } }, - LEVEL3(20, 3, ProfileImage.getUrlByLevel(3)) { //20~39점 + LEVEL3(20, 3) { //20~39점 @Override public Level getNextLevel() { return LEVEL4; } }, - LEVEL4(40, 4, ProfileImage.getUrlByLevel(4)) { //40~59점 + LEVEL4(40, 4) { //40~59점 @Override public Level getNextLevel() { return LEVEL5; } }, - LEVEL5(60, 5, ProfileImage.getUrlByLevel(5)) { //60점 이상 + LEVEL5(60, 5) { //60점 이상 @Override public Level getNextLevel() { return null; @@ -38,7 +38,23 @@ public Level getNextLevel() { }, ; private final int minScore; private final int levelNumber; - private final String profileImage; public abstract Level getNextLevel(); -} \ No newline at end of file + + // 엔티티 필드로 사용되는 enum이 Hibernate 메타데이터 빌드 시점에 초기화되므로, + // ProfileImage 빈 생성 전에 접근하지 않도록 이미지 URL은 호출 시점에 조회한다. + public String getProfileImage() { + return ProfileImage.getUrlByLevel(levelNumber); + } + + public static Level fromScore(long score) { + // score >= minScore 인 경우 중 가장 높은 레벨 반환 + Level result = LEVEL1; + for (Level level : values()) { + if (score >= level.getMinScore() && level.getMinScore() >= result.getMinScore()) { + result = level; + } + } + return result; + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/user/entity/User.java b/src/main/java/devkor/com/teamcback/domain/user/entity/User.java index 52bb55ed..aa33455c 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/entity/User.java +++ b/src/main/java/devkor/com/teamcback/domain/user/entity/User.java @@ -12,6 +12,9 @@ import lombok.Getter; import lombok.NoArgsConstructor; import lombok.Setter; +import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; @Entity @Getter @@ -38,9 +41,23 @@ public class User extends BaseEntity { @Column(nullable = false) private Long score = 0L; + // 스토어에서 사용하는 차감형 재화. score(레벨용 누적치)와 같은 양으로 적립되고 구매 시에만 차감된다. + // 컬럼을 nullable로 두는 이유: 기존 행은 NULL로 생성되고, NULL 여부가 "백필 전" 표식이 되어 백필이 영원히 멱등해진다. + private Long point = 0L; + + // MySQL native enum 타입 대신 VARCHAR로 저장하여 레벨 추가 시 ALTER 없이 확장 가능하게 유지 + @Enumerated(EnumType.STRING) + @JdbcTypeCode(SqlTypes.VARCHAR) + @ColumnDefault("'LEVEL1'") + @Column(nullable = false) + private Level level = Level.LEVEL1; + @Column(nullable = false) private boolean isUpgraded = false; + // 대표 캐릭터 (tb_character 논리 참조, 미장착 시 null) + private Long equippedCharacterId; + @Setter @Column(unique = true) private String code; @@ -60,6 +77,7 @@ public void updateUsername(String username) { public void updateScore(Long score, boolean isUpgraded) { this.score = score; + this.level = Level.fromScore(score); // score와 level이 발산하지 않도록 단일 지점에서 갱신 this.isUpgraded = isUpgraded; } @@ -67,4 +85,20 @@ public void updateUpgraded(boolean isUpgraded) { this.isUpgraded = isUpgraded; } + public void syncLevel() { + this.level = Level.fromScore(this.score); + } + + public Long getPoint() { // 백필 전 NULL 방어 + return point == null ? 0L : point; + } + + public void addPoint(long amount) { + this.point = Math.max(0, getPoint() + amount); + } + + public void updateEquippedCharacter(Long characterId) { + this.equippedCharacterId = characterId; + } + } diff --git a/src/main/java/devkor/com/teamcback/domain/user/repository/UserRepository.java b/src/main/java/devkor/com/teamcback/domain/user/repository/UserRepository.java index 820a9bc0..3db5c675 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/repository/UserRepository.java +++ b/src/main/java/devkor/com/teamcback/domain/user/repository/UserRepository.java @@ -3,6 +3,10 @@ import devkor.com.teamcback.domain.user.entity.Provider; import devkor.com.teamcback.domain.user.entity.User; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; public interface UserRepository extends JpaRepository { boolean existsByUsernameAndUserIdNot(String username, Long id); @@ -12,4 +16,37 @@ public interface UserRepository extends JpaRepository { User findByEmailAndProvider(String email, Provider provider); User findByUserId(long userId); + + /** + * score 기준으로 level 컬럼 백필 (레벨 구간: 0/5/20/40/60, Level enum과 동기화 유지) + * level이 null이거나 score와 불일치하는 행만 갱신하므로 멱등 + */ + @Transactional + @Modifying + @Query(value = """ + UPDATE tb_user SET level = + CASE WHEN score >= 60 THEN 'LEVEL5' WHEN score >= 40 THEN 'LEVEL4' + WHEN score >= 20 THEN 'LEVEL3' WHEN score >= 5 THEN 'LEVEL2' ELSE 'LEVEL1' END + WHERE level IS NULL OR level <> + CASE WHEN score >= 60 THEN 'LEVEL5' WHEN score >= 40 THEN 'LEVEL4' + WHEN score >= 20 THEN 'LEVEL3' WHEN score >= 5 THEN 'LEVEL2' ELSE 'LEVEL1' END + """, nativeQuery = true) + int backfillLevels(); + + /** + * point 컬럼 백필: 기존 사용자는 지금까지 적립한 score만큼 포인트를 보유한 것으로 초기화. + * NULL(백필 전 표식)인 행만 갱신하므로 재실행해도 이미 사용한 포인트가 복구되지 않는다 (멱등) + */ + @Transactional + @Modifying + @Query(value = "UPDATE tb_user SET point = score WHERE point IS NULL", nativeQuery = true) + int backfillPoints(); + + /** + * 포인트 차감. 잔액 검증과 차감을 단일 UPDATE로 수행하여 동시 구매 시 이중 차감을 방지한다. + * @return 1이면 차감 성공, 0이면 잔액 부족 + */ + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("update User u set u.point = u.point - :price where u.userId = :userId and u.point >= :price") + int deductPoint(@Param("userId") Long userId, @Param("price") int price); } diff --git a/src/main/java/devkor/com/teamcback/domain/user/scheduler/UserLevelBackfillRunner.java b/src/main/java/devkor/com/teamcback/domain/user/scheduler/UserLevelBackfillRunner.java new file mode 100644 index 00000000..183e0ded --- /dev/null +++ b/src/main/java/devkor/com/teamcback/domain/user/scheduler/UserLevelBackfillRunner.java @@ -0,0 +1,45 @@ +package devkor.com.teamcback.domain.user.scheduler; + +import devkor.com.teamcback.domain.user.repository.UserRepository; +import devkor.com.teamcback.global.redis.RedisLockUtil; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +/** + * level/point 컬럼 도입에 따른 기존 사용자 백필. + * - level: score와 불일치하는 행만 교정 (정상 상태에서는 0건 갱신) + * - point: NULL인 행만 score 값으로 초기화 (사용 후 0이 된 잔액은 다시 채워지지 않음) + * 매 부팅 시 실행되지만 두 쿼리 모두 멱등. + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class UserLevelBackfillRunner { + private final UserRepository userRepository; + private final RedisLockUtil redisLockUtil; + + @Value("${metrics.environment}") + private String env; + + // 트랜잭션 경계는 UserRepository.backfillLevels()에 둔다. 리스너 메서드를 @Transactional로 감싸면 + // UPDATE 실패를 catch해도 rollback-only 커밋 예외가 리스너 밖으로 나가 부팅이 실패한다. + @EventListener(ApplicationReadyEvent.class) + public void backfillUserLevels() { + if(env.equals("test")) return; // H2 테스트 환경은 create-drop이라 백필 불필요 + Redis 미기동 + + try { + redisLockUtil.executeWithLock("user_level_backfill_lock", 1, 300, () -> { + int levelUpdated = userRepository.backfillLevels(); + int pointUpdated = userRepository.backfillPoints(); + log.info("사용자 백필 완료: level {}건, point {}건 갱신", levelUpdated, pointUpdated); + return null; + }); + } catch (Exception e) { + log.error("backfillUserLevels() 작업 실패: {}", e.getMessage(), e); + } + } +} diff --git a/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java b/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java index 4b5831a5..c7f4c85d 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java +++ b/src/main/java/devkor/com/teamcback/domain/user/service/UserService.java @@ -7,10 +7,14 @@ import devkor.com.teamcback.domain.bookmark.repository.BookmarkRepository; import devkor.com.teamcback.domain.bookmark.repository.CategoryRepository; import devkor.com.teamcback.domain.bookmark.repository.UserBookmarkLogRepository; +import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; +import devkor.com.teamcback.domain.notification.service.PushInstallationService; import devkor.com.teamcback.domain.suggestion.entity.Suggestion; import devkor.com.teamcback.domain.suggestion.repository.SuggestionRepository; import devkor.com.teamcback.domain.user.dto.request.BypassLoginReq; +import devkor.com.teamcback.domain.user.dto.request.AdminLoginReq; import devkor.com.teamcback.domain.user.dto.request.LoginUserReq; +import devkor.com.teamcback.domain.user.dto.response.AdminLoginRes; import devkor.com.teamcback.domain.user.dto.response.BypassLoginRes; import devkor.com.teamcback.domain.user.dto.response.DeleteUserRes; import devkor.com.teamcback.domain.user.dto.response.GetUserInfoRes; @@ -36,9 +40,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.Arrays; -import java.util.Comparator; - import static devkor.com.teamcback.global.response.ResultCode.*; @Slf4j @@ -50,11 +51,13 @@ public class UserService { private final BookmarkRepository bookmarkRepository; private final UserBookmarkLogRepository userBookmarkLogRepository; private final SuggestionRepository suggestionRepository; + private final UserCharacterRepository userCharacterRepository; private final JwtUtil jwtUtil; private final KakaoValidator kakaoValidator; private final GoogleValidator googleValidator; private final AppleValidator appleValidator; private final PasswordEncoder passwordEncoder; + private final PushInstallationService pushInstallationService; private static final String DEFAULT_NAME = "호랑이"; private static final String DEFAULT_CATEGORY = "내 장소"; @@ -69,7 +72,11 @@ public class UserService { @Transactional public GetUserInfoRes getUserInfo(Long userId) { User user = findUser(userId); - Level level = getLevel(user.getScore()); + Level level = user.getLevel(); + if(level == null) { // 백필 전 레거시 행 방어 (백필 완료 후엔 도달하지 않음) + user.syncLevel(); + level = user.getLevel(); + } Level nextLevel = level.getNextLevel(); Long remainScoreToNextLevel = nextLevel == null ? 0 : nextLevel.getMinScore() - user.getScore(); int percent = 100; @@ -109,7 +116,12 @@ public LoginUserRes releaseLogin(LoginUserReq loginUserReq) { String rawCode = UUID.randomUUID().toString(); user.setCode(passwordEncoder.encode(rawCode)); - return new LoginUserRes(jwtUtil.createAccessToken(user.getUserId().toString(), user.getRole().getAuthority()), jwtUtil.createRefreshToken(user.getUserId().toString(), user.getRole().getAuthority()), rawCode); + return new LoginUserRes( + jwtUtil.createAccessToken(user.getUserId().toString(), user.getRole().getAuthority()), + jwtUtil.createRefreshToken(user.getUserId().toString(), user.getRole().getAuthority()), + rawCode, + email + ); } private String validateToken(Provider provider, String token) { @@ -121,6 +133,32 @@ private String validateToken(Provider provider, String token) { }; } + /** + * 관리자 대시보드용 소셜 로그인. + * 일반 로그인과 달리 신규 사용자를 만들지 않으며 기존 ADMIN 계정만 토큰을 발급한다. + */ + @Transactional(readOnly = true) + public AdminLoginRes adminLogin(AdminLoginReq adminLoginReq) { + Provider provider = adminLoginReq.getProvider(); + if(provider != Provider.GOOGLE && provider != Provider.KAKAO) { + throw new GlobalException(INVALID_INPUT); + } + + String email = provider == Provider.KAKAO + ? kakaoValidator.validateAdminToken(adminLoginReq.getToken()) + : googleValidator.validateToken(adminLoginReq.getToken()); + User user = userRepository.findByEmailAndProvider(email, provider); + if(user == null || user.getRole() != Role.ADMIN) { + throw new GlobalException(FORBIDDEN); + } + + return new AdminLoginRes( + jwtUtil.createAccessToken(user.getUserId().toString(), user.getRole().getAuthority()), + jwtUtil.createRefreshToken(user.getUserId().toString(), user.getRole().getAuthority()), + user + ); + } + /** * 자동 로그인 */ @@ -183,7 +221,9 @@ public DeleteUserRes deleteUser(Long userId) { } userBookmarkLogRepository.deleteAll(userBookmarkLogRepository.findByUser(user)); + pushInstallationService.deactivateAll(user.getUserId()); // suggestionRepository.deleteAll(suggestionRepository.findByUser(user)); + userCharacterRepository.deleteAllByUser(user); userRepository.delete(user); return new DeleteUserRes(); @@ -214,14 +254,6 @@ private void checkUsernameAvailability(User user, String username) { } } - private Level getLevel(Long score) { - // score >= minScore 인 경우 중 가장 높은 레벨 반환 - return Arrays.stream(Level.values()) - .filter(lv -> score >= lv.getMinScore()) - .max(Comparator.comparingInt(Level::getMinScore)) - .orElse(Level.LEVEL1); - } - private User findUser(Long userId) { return userRepository.findById(userId).orElseThrow(() -> new GlobalException(NOT_FOUND_USER)); } diff --git a/src/main/java/devkor/com/teamcback/domain/user/validator/KakaoValidator.java b/src/main/java/devkor/com/teamcback/domain/user/validator/KakaoValidator.java index 9415ebb0..7b3826d1 100644 --- a/src/main/java/devkor/com/teamcback/domain/user/validator/KakaoValidator.java +++ b/src/main/java/devkor/com/teamcback/domain/user/validator/KakaoValidator.java @@ -25,15 +25,25 @@ public class KakaoValidator{ private String ISS; @Value("${jwt.social.kakao.aud}") private String AUD; + @Value("${jwt.social.kakao.admin-aud:}") + private String ADMIN_AUD; public OIDCPublicKeysResponse getCachedData() { return kakaoClient.getPublicKeys(); } public String validateToken(String token) { + return validateToken(token, AUD); + } + + public String validateAdminToken(String token) { + return validateToken(token, ADMIN_AUD.isBlank() ? AUD : ADMIN_AUD); + } + + private String validateToken(String token, String audience) { try { // 카카오 id_token 정보 - String kid = oidcUtil.getKidFromUnsignedTokenHeader(token, AUD, ISS); + String kid = oidcUtil.getKidFromUnsignedTokenHeader(token, audience, ISS); // 공개키 가져오기 OIDCPublicKeysResponse publicKeysResponse = getCachedData(); @@ -46,7 +56,7 @@ public String validateToken(String token) { OIDCDecodePayload payload = oidcUtil.getOIDCTokenBody(token, oidcPublicKeyDto.getN(), oidcPublicKeyDto.getE()); - return payload.getEmail(); + return resolveEmail(payload); } catch(GlobalException e) { redisUtil.deleteCache("kakao::data"); throw new GlobalException(e.getResultCode()); @@ -54,4 +64,18 @@ public String validateToken(String token) { throw new GlobalException(INVALID_TOKEN); } } + + String resolveEmail(OIDCDecodePayload payload) { + String email = payload.getEmail(); + if (email != null && !email.isBlank()) { + return email; + } + + String subject = payload.getSub(); + if (subject == null || subject.isBlank()) { + throw new GlobalException(INVALID_TOKEN); + } + + return "kakao_" + subject + "@noemail.kodaero.invalid"; + } } diff --git a/src/main/java/devkor/com/teamcback/global/aop/UpdateScoreAspect.java b/src/main/java/devkor/com/teamcback/global/aop/UpdateScoreAspect.java index c0ba2a22..5e8c6071 100644 --- a/src/main/java/devkor/com/teamcback/global/aop/UpdateScoreAspect.java +++ b/src/main/java/devkor/com/teamcback/global/aop/UpdateScoreAspect.java @@ -27,8 +27,6 @@ import java.time.LocalDate; import java.time.LocalDateTime; -import java.util.Arrays; -import java.util.Comparator; import static devkor.com.teamcback.global.response.ResultCode.*; @@ -336,34 +334,30 @@ private void injectScoreInfo(Object result, User user, boolean isLevelUp, boolea public void increaseScore(User user, int addScore) { long newScore = user.getScore() + addScore; // 전후 레벨 계산 - Level beforeLv = getLevel(user.getScore()); - Level afterLv = getLevel(newScore); + Level beforeLv = Level.fromScore(user.getScore()); + Level afterLv = Level.fromScore(newScore); // 변했으면 true boolean isChanged = beforeLv != afterLv; user.updateScore(newScore, isChanged); + user.addPoint(addScore); // 스토어 재화는 score와 같은 양으로 적립 } /** * 점수 차이만큼 업데이트 (증가 또는 감소) */ private void updateScoreWithDiff(User user, int scoreDiff) { - long newScore = Math.max(0, user.getScore() + scoreDiff); // 최소 0점 + long oldScore = user.getScore(); + long newScore = Math.max(0, oldScore + scoreDiff); // 최소 0점 // 전후 레벨 계산 - Level beforeLv = getLevel(user.getScore()); - Level afterLv = getLevel(newScore); + Level beforeLv = Level.fromScore(oldScore); + Level afterLv = Level.fromScore(newScore); // 변했으면 true boolean isChanged = beforeLv != afterLv; user.updateScore(newScore, isChanged); - } - - private Level getLevel(Long score) { - // score >= minScore 인 경우 중 가장 높은 레벨 반환 - return Arrays.stream(Level.values()) - .filter(lv -> score >= lv.getMinScore()) - .max(Comparator.comparingInt(Level::getMinScore)) - .orElse(Level.LEVEL1); + // 리뷰 삭제 후 재작성으로 포인트를 무한 적립하는 것을 막기 위해 차감도 동일하게 반영 (최소 0) + user.addPoint(newScore - oldScore); } } diff --git a/src/main/java/devkor/com/teamcback/global/config/TimeConfig.java b/src/main/java/devkor/com/teamcback/global/config/TimeConfig.java new file mode 100644 index 00000000..1d7057ac --- /dev/null +++ b/src/main/java/devkor/com/teamcback/global/config/TimeConfig.java @@ -0,0 +1,16 @@ +package devkor.com.teamcback.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.time.Clock; +import java.time.ZoneId; + +@Configuration +public class TimeConfig { + + @Bean + public Clock clock() { + return Clock.system(ZoneId.of("Asia/Seoul")); + } +} diff --git a/src/main/java/devkor/com/teamcback/global/jwt/JwtAuthorizationFilter.java b/src/main/java/devkor/com/teamcback/global/jwt/JwtAuthorizationFilter.java index 434654cf..72080a15 100644 --- a/src/main/java/devkor/com/teamcback/global/jwt/JwtAuthorizationFilter.java +++ b/src/main/java/devkor/com/teamcback/global/jwt/JwtAuthorizationFilter.java @@ -53,7 +53,6 @@ protected void doFilterInternal( throws ServletException, IOException { String accessToken = jwtUtil.getAccessTokenFromHeader(request); - log.info("Access Token: {}", accessToken); // access token 비어있으면 인증 미처리 if (!StringUtils.hasText(accessToken)) { diff --git a/src/main/java/devkor/com/teamcback/global/response/CommonResponse.java b/src/main/java/devkor/com/teamcback/global/response/CommonResponse.java index ee250761..cf9b1072 100644 --- a/src/main/java/devkor/com/teamcback/global/response/CommonResponse.java +++ b/src/main/java/devkor/com/teamcback/global/response/CommonResponse.java @@ -33,5 +33,5 @@ public CommonResponse(ResultCode resultCode, T data) { public static CommonResponse success(T data) { return new CommonResponse<>(ResultCode.SUCCESS, data); } - + public static CommonResponse success() {return new CommonResponse<>(ResultCode.SUCCESS);} } diff --git a/src/main/java/devkor/com/teamcback/global/response/ResultCode.java b/src/main/java/devkor/com/teamcback/global/response/ResultCode.java index bad5cd71..9287f63a 100644 --- a/src/main/java/devkor/com/teamcback/global/response/ResultCode.java +++ b/src/main/java/devkor/com/teamcback/global/response/ResultCode.java @@ -102,9 +102,32 @@ public enum ResultCode { COMMENT_TOO_SHORT(HttpStatus.BAD_REQUEST, 15003, "한줄평은 10글자 이상 작성해주세요."), // 신고 16000번대 - NOT_FOUND_REPORT(HttpStatus.NOT_FOUND, 16000, "신고를 찾을 수 없습니다."); + NOT_FOUND_REPORT(HttpStatus.NOT_FOUND, 16000, "신고를 찾을 수 없습니다."), + + // notification 17000 + NOT_FOUND_PUSH_INSTALLATION(HttpStatus.NOT_FOUND, 17000, "Push installation not found."), + FORBIDDEN_PUSH_INSTALLATION(HttpStatus.FORBIDDEN, 17001, "Push installation is not owned by the current user."), + INACTIVE_PUSH_INSTALLATION(HttpStatus.BAD_REQUEST, 17002, "Push installation is inactive."), + UNSUPPORTED_PUSH_INSTALLATION_VARIANT(HttpStatus.BAD_REQUEST, 17003, "Push installation variant is not supported for test push."), + EXPO_PUSH_RETRYABLE_ERROR(HttpStatus.SERVICE_UNAVAILABLE, 17004, "Expo push request failed with retryable error."), + EXPO_PUSH_NON_RETRYABLE_ERROR(HttpStatus.BAD_GATEWAY, 17005, "Expo push request failed with non-retryable error."), + EXPO_PUSH_TICKET_ERROR(HttpStatus.BAD_GATEWAY, 17006, "Expo push ticket returned error status."), + + // 캐릭터 18000번대 + NOT_FOUND_CHARACTER(HttpStatus.NOT_FOUND, 18000, "캐릭터를 찾을 수 없습니다."), + ALREADY_OWNED_CHARACTER(HttpStatus.CONFLICT, 18001, "이미 보유한 캐릭터입니다."), + INSUFFICIENT_POINT(HttpStatus.BAD_REQUEST, 18002, "포인트가 부족합니다."), + NOT_OWNED_CHARACTER(HttpStatus.BAD_REQUEST, 18003, "보유하지 않은 캐릭터입니다."), + CHARACTER_IN_USE(HttpStatus.CONFLICT, 18004, "사용자가 보유 중인 캐릭터는 삭제할 수 없습니다."), + INACTIVE_CHARACTER(HttpStatus.BAD_REQUEST, 18005, "비활성화된 캐릭터입니다."), + INSUFFICIENT_LEVEL(HttpStatus.BAD_REQUEST, 18006, "레벨이 부족합니다."), + + // 사용 성향 조사 19000번대 + ALREADY_ANSWERED_USAGE_SURVEY(HttpStatus.CONFLICT, 19000, "이미 응답한 조사 문항입니다."), + INVALID_USAGE_SURVEY_OPTION(HttpStatus.BAD_REQUEST, 19001, "유효하지 않은 조사 응답입니다."); + private final HttpStatus status; private final int code; private final String message; -} \ No newline at end of file +} diff --git a/src/main/java/devkor/com/teamcback/global/security/SecurityConfig.java b/src/main/java/devkor/com/teamcback/global/security/SecurityConfig.java index 77695172..90e13621 100644 --- a/src/main/java/devkor/com/teamcback/global/security/SecurityConfig.java +++ b/src/main/java/devkor/com/teamcback/global/security/SecurityConfig.java @@ -82,20 +82,24 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti ); http.authorizeHttpRequests((authorizeHttpRequests) -> - authorizeHttpRequests - .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() // resources 접근 허용 설정 - .requestMatchers(HttpMethod.POST, "/api/search/**").authenticated() - .requestMatchers("/api/users/login/**").permitAll() // 로그인은 허용 - .requestMatchers("/api/users/**").authenticated() - .requestMatchers("/api/admin/**").hasRole("ADMIN") // 관리자인 경우에만 허용 - .requestMatchers("/api/categories/**").authenticated() - .requestMatchers("/api/bookmarks/**").authenticated() - .requestMatchers(HttpMethod.POST, "/api/reviews/**").authenticated() // 리뷰는 로그인 필요 - .requestMatchers("/api/reports/status").authenticated() // 신고 상태 확인은 로그인 필요 - .anyRequest().permitAll() + authorizeHttpRequests + .requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll() // resources 접근 허용 설정 + .requestMatchers(HttpMethod.POST, "/api/search/**").authenticated() + .requestMatchers("/api/users/login/**").permitAll() // 로그인은 허용 + .requestMatchers("/api/users/**").authenticated() + .requestMatchers("/api/admin/**").hasRole("ADMIN") // 관리자인 경우에만 허용 + .requestMatchers("/api/categories/**").authenticated() + .requestMatchers("/api/bookmarks/**").authenticated() + .requestMatchers(HttpMethod.POST, "/api/reviews/**").authenticated() // 리뷰는 로그인 필요 + .requestMatchers("/api/reports/status").authenticated() // 신고 상태 확인은 로그인 필요 + .requestMatchers("/api/notifications/installations/**").authenticated() // 토큰 등록 로그인 필요 + .requestMatchers("/api/store/**").authenticated() // 캐릭터 스토어는 로그인 필요 + .requestMatchers("/api/usage-surveys/**").authenticated() // 사용 성향 조사는 로그인 필요 + .requestMatchers(HttpMethod.POST, "/api/notifications/test").authenticated() + .anyRequest().permitAll() ).exceptionHandling(ex -> ex - .accessDeniedHandler(customAccessDeniedHandler()) // 인가 실패 시 - .authenticationEntryPoint(customAuthenticationEntryPoint()) // 인증 실패 시 + .accessDeniedHandler(customAccessDeniedHandler()) // 인가 실패 시 + .authenticationEntryPoint(customAuthenticationEntryPoint()) // 인증 실패 시 ); http.logout( diff --git a/src/main/java/devkor/com/teamcback/infra/s3/FilePath.java b/src/main/java/devkor/com/teamcback/infra/s3/FilePath.java index 4c75cbbe..8e3fab61 100644 --- a/src/main/java/devkor/com/teamcback/infra/s3/FilePath.java +++ b/src/main/java/devkor/com/teamcback/infra/s3/FilePath.java @@ -11,7 +11,8 @@ public enum FilePath { // 파일 경로를 나타내는 상수를 정의 PLACE("place/"), BUILDING_IMAGE("buildingImage/"), SUGGESTION("suggestion/"), - REVIEW("review/"); + REVIEW("review/"), + CHARACTER("character/"); private final String path; // 경로를 저장하는 final 필드 } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 64b76d02..4e0a16d6 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -75,6 +75,7 @@ jwt: kakao: iss: ${KAKAO_ISS} aud: ${KAKAO_AUD} + admin-aud: ${KAKAO_ADMIN_AUD:} google: iss: ${GOOGLE_ISS} aud: ${GOOGLE_AUD} @@ -119,6 +120,10 @@ profile: lv4-url: https://kodaero-ku.s3.ap-northeast-2.amazonaws.com/profile/lv4.jpg lv5-url: https://kodaero-ku.s3.ap-northeast-2.amazonaws.com/profile/lv5.jpg +character: + image: # 캐릭터 이미지 (피그마 확정 이미지를 이 경로 규칙대로 S3에 업로드: {base-url}/{NN_slug}.png) + base-url: https://kodaero-ku.s3.ap-northeast-2.amazonaws.com/character + place: default-image: cafe: https://kodaero-ku.s3.ap-northeast-2.amazonaws.com/place/default_image/CAFE.jpg @@ -152,3 +157,35 @@ management: staff: emails: leeyejin113@gmail.com,pingdoll3110@naver.com,ku.kodaero@gmail.com + +push: + expo: + base-url: https://exp.host/--/api/v2/push + access-token: ${EXPO_ACCESS_TOKEN:} + connect-timeout: 3s + read-timeout: 10s + event: + # Dev server sets DEV,PREVIEW; production safely defaults to PRODUCTION. + target-app-variants: ${PUSH_EVENT_TARGET_APP_VARIANTS:PRODUCTION} + crowd-enabled: ${PUSH_EVENT_CROWD_ENABLED:false} + report-enabled: ${PUSH_EVENT_REPORT_ENABLED:false} + character-enabled: ${PUSH_EVENT_CHARACTER_ENABLED:false} + survey-enabled: ${PUSH_EVENT_SURVEY_ENABLED:false} + survey: + poll-interval-ms: ${PUSH_SURVEY_POLL_INTERVAL_MS:60000} + worker: + enabled: ${PUSH_WORKER_ENABLED:false} + fixed-delay-ms: ${PUSH_WORKER_FIXED_DELAY_MS:5000} + batch-size: ${PUSH_WORKER_BATCH_SIZE:100} + max-send-attempts: ${PUSH_WORKER_MAX_SEND_ATTEMPTS:3} + retry-delay-ms: ${PUSH_WORKER_RETRY_DELAY_MS:30000} + receipt-worker: + enabled: ${PUSH_RECEIPT_WORKER_ENABLED:false} + fixed-delay-ms: ${PUSH_RECEIPT_WORKER_FIXED_DELAY_MS:60000} + batch-size: ${PUSH_RECEIPT_WORKER_BATCH_SIZE:1000} + max-receipt-attempts: ${PUSH_RECEIPT_WORKER_MAX_RECEIPT_ATTEMPTS:3} + recovery-worker: + enabled: ${PUSH_RECOVERY_WORKER_ENABLED:false} + cron: "${PUSH_RECOVERY_WORKER_CRON:0 0 4 * * *}" + stale-threshold-minutes: ${PUSH_RECOVERY_STALE_THRESHOLD_MINUTES:30} + batch-size: ${PUSH_RECOVERY_BATCH_SIZE:100} diff --git a/src/test/java/devkor/com/teamcback/domain/ble/service/BLEServiceTest.java b/src/test/java/devkor/com/teamcback/domain/ble/service/BLEServiceTest.java new file mode 100644 index 00000000..25caebab --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/ble/service/BLEServiceTest.java @@ -0,0 +1,144 @@ +package devkor.com.teamcback.domain.ble.service; + +import devkor.com.teamcback.domain.ble.dto.request.UpdateBLEReq; +import devkor.com.teamcback.domain.ble.entity.BLEData; +import devkor.com.teamcback.domain.ble.entity.BLEDevice; +import devkor.com.teamcback.domain.ble.entity.BLEstatus; +import devkor.com.teamcback.domain.ble.event.PlaceBecameVacantEvent; +import devkor.com.teamcback.domain.ble.repository.BLEDataRepository; +import devkor.com.teamcback.domain.ble.repository.BLEDeviceRepository; +import devkor.com.teamcback.domain.place.entity.Place; +import devkor.com.teamcback.domain.place.repository.PlaceRepository; +import java.time.LocalDateTime; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class BLEServiceTest { + + @Mock + private BLEDeviceRepository bleDeviceRepository; + + @Mock + private BLEDataRepository bleDataRepository; + + @Mock + private PlaceRepository placeRepository; + + @Mock + private ApplicationEventPublisher eventPublisher; + + private BLEService bleService; + private BLEDevice device; + + @BeforeEach + void setUp() { + bleService = new BLEService( + bleDeviceRepository, + bleDataRepository, + placeRepository, + eventPublisher + ); + + Place place = new Place(); + ReflectionTestUtils.setField(place, "id", 10L); + + device = new BLEDevice(); + ReflectionTestUtils.setField(device, "id", 3L); + device.setDeviceName("device-1"); + device.setCapacity(100); + device.setDefaultCount(0); + device.setRatio(1); + device.setPlace(place); + + when(bleDeviceRepository.findByDeviceName("device-1")).thenReturn(device); + when(bleDataRepository.save(any(BLEData.class))).thenAnswer(invocation -> { + BLEData saved = invocation.getArgument(0); + ReflectionTestUtils.setField(saved, "id", 99L); + return saved; + }); + } + + @Test + void publishesEventWhenAvailableBecomesVacant() { + when(bleDataRepository.findTopByDeviceOrderByLastTimeDescIdDesc(device)) + .thenReturn(Optional.of(data(BLEstatus.AVAILABLE))); + + bleService.updateBLE(req(20)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PlaceBecameVacantEvent.class); + verify(eventPublisher).publishEvent(captor.capture()); + assertThat(captor.getValue().placeId()).isEqualTo(10L); + assertThat(captor.getValue().bleDataId()).isEqualTo(99L); + } + + @Test + void publishesEventWhenCrowdedBecomesVacant() { + when(bleDataRepository.findTopByDeviceOrderByLastTimeDescIdDesc(device)) + .thenReturn(Optional.of(data(BLEstatus.CROWDED))); + + bleService.updateBLE(req(20)); + + verify(eventPublisher).publishEvent(any(PlaceBecameVacantEvent.class)); + } + + @Test + void doesNotPublishForRepeatedVacant() { + when(bleDataRepository.findTopByDeviceOrderByLastTimeDescIdDesc(device)) + .thenReturn(Optional.of(data(BLEstatus.VACANT))); + + bleService.updateBLE(req(20)); + + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + void doesNotPublishWhenPreviousDataDoesNotExist() { + when(bleDataRepository.findTopByDeviceOrderByLastTimeDescIdDesc(device)) + .thenReturn(Optional.empty()); + + bleService.updateBLE(req(20)); + + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + void doesNotPublishWhenPreviousStatusIsFailure() { + when(bleDataRepository.findTopByDeviceOrderByLastTimeDescIdDesc(device)) + .thenReturn(Optional.of(data(BLEstatus.FAILURE))); + + bleService.updateBLE(req(20)); + + verify(eventPublisher, never()).publishEvent(any()); + } + + private UpdateBLEReq req(int lastCount) { + UpdateBLEReq req = new UpdateBLEReq(); + req.setDeviceName("device-1"); + req.setLastCount(lastCount); + req.setLastTime(LocalDateTime.parse("2026-08-05T10:00:00")); + return req; + } + + private BLEData data(BLEstatus status) { + BLEData data = new BLEData(); + data.setDevice(device); + data.setLastStatus(status); + data.setLastCount(50); + data.setLastTime(LocalDateTime.parse("2026-08-05T09:59:00")); + return data; + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/character/repository/CharacterRepositoryTest.java b/src/test/java/devkor/com/teamcback/domain/character/repository/CharacterRepositoryTest.java new file mode 100644 index 00000000..bca6aedd --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/character/repository/CharacterRepositoryTest.java @@ -0,0 +1,65 @@ +package devkor.com.teamcback.domain.character.repository; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import devkor.com.teamcback.domain.character.entity.KoCharacter; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import devkor.com.teamcback.global.config.QueryDslConfig; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.context.ActiveProfiles; + +@DataJpaTest +@ActiveProfiles("test") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import(QueryDslConfig.class) // @Repository인 QueryDSL 커스텀 구현체 스캔에 필요 +class CharacterRepositoryTest { + @Autowired + CharacterRepository characterRepository; + + @BeforeEach + void setUp() { + characterRepository.save(new KoCharacter("두번째", null, null, "url2", 20, 2, 2, true)); + characterRepository.save(new KoCharacter("첫번째", null, null, "url1", 10, 1, 1, true)); + characterRepository.save(new KoCharacter("비활성", null, null, "url3", 30, 3, 3, false)); + } + + @DisplayName("활성 캐릭터만 정렬 순서대로 조회") + @Test + void findAllByIsActiveTrueOrderByDisplayOrderAsc() { + List characters = characterRepository.findAllByIsActiveTrueOrderByDisplayOrderAsc(); + + assertEquals(2, characters.size()); + assertEquals("첫번째", characters.get(0).getName()); + assertEquals("두번째", characters.get(1).getName()); + } + + @DisplayName("관리자 조회는 비활성 포함") + @Test + void findAllByOrderByDisplayOrderAsc() { + assertEquals(3, characterRepository.findAllByOrderByDisplayOrderAsc().size()); + } + + @DisplayName("이름 존재 여부 확인 (시더 멱등 처리용)") + @Test + void existsByName() { + assertTrue(characterRepository.existsByName("첫번째")); + assertFalse(characterRepository.existsByName("없는이름")); + } + + @DisplayName("이름 중복 저장 시 제약 위반 (멀티 인스턴스 시드 레이스 방어)") + @Test + void duplicateNameThrows() { + assertThrows(DataIntegrityViolationException.class, () -> + characterRepository.saveAndFlush(new KoCharacter("첫번째", null, null, "url", 10, 1, 9, true))); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/character/repository/UserCharacterRepositoryTest.java b/src/test/java/devkor/com/teamcback/domain/character/repository/UserCharacterRepositoryTest.java new file mode 100644 index 00000000..eff13a19 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/character/repository/UserCharacterRepositoryTest.java @@ -0,0 +1,86 @@ +package devkor.com.teamcback.domain.character.repository; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import devkor.com.teamcback.domain.character.entity.KoCharacter; +import devkor.com.teamcback.domain.character.entity.UserCharacter; +import devkor.com.teamcback.domain.user.entity.Provider; +import devkor.com.teamcback.domain.user.entity.Role; +import devkor.com.teamcback.domain.user.entity.User; +import devkor.com.teamcback.domain.user.repository.UserRepository; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import devkor.com.teamcback.global.config.QueryDslConfig; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.context.ActiveProfiles; + +@DataJpaTest +@ActiveProfiles("test") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import(QueryDslConfig.class) // @Repository인 QueryDSL 커스텀 구현체 스캔에 필요 +class UserCharacterRepositoryTest { + @Autowired + UserCharacterRepository userCharacterRepository; + + @Autowired + CharacterRepository characterRepository; + + @Autowired + UserRepository userRepository; + + User user1; + User user2; + KoCharacter character; + + @BeforeEach + void setUp() { + user1 = userRepository.save(new User("user1", "user1@test.com", Role.USER, Provider.KAKAO)); + user2 = userRepository.save(new User("user2", "user2@test.com", Role.USER, Provider.KAKAO)); + character = characterRepository.save(new KoCharacter("캐릭터", null, null, "url", 10, 1, 1, true)); + + userCharacterRepository.save(new UserCharacter(user1, character)); + } + + @DisplayName("같은 사용자-캐릭터 중복 저장 시 제약 위반 (동시 해금 방어선)") + @Test + void duplicateClaimThrows() { + assertThrows(DataIntegrityViolationException.class, () -> + userCharacterRepository.saveAndFlush(new UserCharacter(user1, character))); + } + + @DisplayName("사용자별 보유 캐릭터만 조회") + @Test + void findAllByUser() { + List owned = userCharacterRepository.findAllByUser(user1); + + assertEquals(1, owned.size()); + assertEquals(character.getCharacterId(), owned.get(0).getCharacter().getCharacterId()); + assertTrue(userCharacterRepository.findAllByUser(user2).isEmpty()); + } + + @DisplayName("보유 여부 확인") + @Test + void existsByUserAndCharacter() { + assertTrue(userCharacterRepository.existsByUserAndCharacter(user1, character)); + assertTrue(userCharacterRepository.existsByCharacter(character)); + } + + @DisplayName("회원 탈퇴 시 본인 보유 이력만 삭제") + @Test + void deleteAllByUser() { + userCharacterRepository.save(new UserCharacter(user2, character)); + + userCharacterRepository.deleteAllByUser(user1); + + assertTrue(userCharacterRepository.findAllByUser(user1).isEmpty()); + assertEquals(1, userCharacterRepository.findAllByUser(user2).size()); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/character/service/AdminStoreServiceTest.java b/src/test/java/devkor/com/teamcback/domain/character/service/AdminStoreServiceTest.java new file mode 100644 index 00000000..09020cbb --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/character/service/AdminStoreServiceTest.java @@ -0,0 +1,209 @@ +package devkor.com.teamcback.domain.character.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import devkor.com.teamcback.domain.character.dto.request.CreateCharacterReq; +import devkor.com.teamcback.domain.character.dto.response.CreateCharacterRes; +import devkor.com.teamcback.domain.character.entity.KoCharacter; +import devkor.com.teamcback.domain.character.entity.UserCharacter; +import devkor.com.teamcback.domain.character.event.CharacterUnlockedEvent; +import devkor.com.teamcback.domain.character.repository.CharacterRepository; +import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; +import devkor.com.teamcback.domain.user.entity.Provider; +import devkor.com.teamcback.domain.user.entity.Role; +import devkor.com.teamcback.domain.user.entity.User; +import devkor.com.teamcback.domain.user.repository.UserRepository; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import devkor.com.teamcback.global.response.ResultCode; +import devkor.com.teamcback.infra.s3.FilePath; +import devkor.com.teamcback.infra.s3.S3Util; +import java.util.Optional; +import org.mockito.ArgumentCaptor; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class AdminStoreServiceTest { + @InjectMocks + AdminStoreService adminStoreService; + + @Mock + CharacterRepository characterRepository; + @Mock + UserCharacterRepository userCharacterRepository; + @Mock + UserRepository userRepository; + @Mock + S3Util s3Util; + @Mock + ApplicationEventPublisher eventPublisher; + + @DisplayName("캐릭터 생성 시 S3 업로드 후 URL과 가격 저장") + @Test + void createCharacter() { + MockMultipartFile image = new MockMultipartFile("image", "tiger.png", "image/png", new byte[] {1}); + CreateCharacterReq req = new CreateCharacterReq(); + req.setName("아기 호랑이"); + req.setPrice(10); + req.setDisplayOrder(1); + req.setImage(image); + + when(s3Util.uploadFile(image, FilePath.CHARACTER)).thenReturn("https://s3/character/tiger.png"); + when(characterRepository.save(any(KoCharacter.class))).thenAnswer(invocation -> { + KoCharacter character = invocation.getArgument(0); + ReflectionTestUtils.setField(character, "characterId", 1L); + return character; + }); + + CreateCharacterRes res = adminStoreService.createCharacter(req); + + assertEquals(1L, res.getCharacterId()); + verify(s3Util).uploadFile(image, FilePath.CHARACTER); + } + + @DisplayName("이미지 없이 캐릭터 생성 시 예외") + @Test + void createCharacterWithoutImage() { + CreateCharacterReq req = new CreateCharacterReq(); + req.setName("아기 호랑이"); + req.setPrice(10); + + GlobalException e = assertThrows(GlobalException.class, + () -> adminStoreService.createCharacter(req)); + assertEquals(ResultCode.INVALID_INPUT, e.getResultCode()); + verify(characterRepository, never()).save(any()); + } + + @DisplayName("가격이 없거나 음수면 생성 불가") + @Test + void createCharacterInvalidPrice() { + CreateCharacterReq req = new CreateCharacterReq(); + req.setName("아기 호랑이"); + req.setPrice(-1); + + GlobalException e = assertThrows(GlobalException.class, + () -> adminStoreService.createCharacter(req)); + assertEquals(ResultCode.INVALID_INPUT, e.getResultCode()); + + req.setPrice(null); + assertThrows(GlobalException.class, () -> adminStoreService.createCharacter(req)); + } + + @DisplayName("해금 레벨이 1~5 범위를 벗어나면 생성 불가") + @Test + void createCharacterInvalidRequiredLevel() { + CreateCharacterReq req = new CreateCharacterReq(); + req.setName("아기 호랑이"); + req.setPrice(10); + req.setRequiredLevel(0); + + GlobalException e = assertThrows(GlobalException.class, + () -> adminStoreService.createCharacter(req)); + assertEquals(ResultCode.INVALID_INPUT, e.getResultCode()); + + req.setRequiredLevel(6); // Level enum 최대(5) 초과 + assertThrows(GlobalException.class, () -> adminStoreService.createCharacter(req)); + verify(characterRepository, never()).save(any()); + } + + @DisplayName("캐릭터 수정: 이미지 미첨부 시 기존 이미지 유지, 첨부 시 교체 후 기존 파일 삭제") + @Test + void modifyCharacter() { + KoCharacter character = new KoCharacter("애기호랑이", null, "옛 대사", "old-url", 0, 1, 1, true); + when(characterRepository.findById(1L)).thenReturn(Optional.of(character)); + + devkor.com.teamcback.domain.character.dto.request.ModifyCharacterReq req = + new devkor.com.teamcback.domain.character.dto.request.ModifyCharacterReq(); + req.setName("애기호랑이"); + req.setQuote("나 호랑이 맞아요?"); + req.setPrice(0); + req.setRequiredLevel(1); + req.setDisplayOrder(1); + + // 이미지 미첨부 → 기존 URL 유지, S3 접근 없음 + adminStoreService.modifyCharacter(1L, req); + assertEquals("old-url", character.getImageUrl()); + assertEquals("나 호랑이 맞아요?", character.getQuote()); + verify(s3Util, never()).deleteFile(any(String.class), any(FilePath.class)); + + // 이미지 첨부 → 새 URL로 교체 + 기존 S3 파일 삭제 + MockMultipartFile image = new MockMultipartFile("image", "new.png", "image/png", new byte[] {1}); + req.setImage(image); + when(s3Util.uploadFile(image, FilePath.CHARACTER)).thenReturn("new-url"); + + adminStoreService.modifyCharacter(1L, req); + assertEquals("new-url", character.getImageUrl()); + verify(s3Util).deleteFile(eq("old-url"), eq(FilePath.CHARACTER)); + } + + @DisplayName("구매한 사용자가 있는 캐릭터 삭제 시 예외") + @Test + void deleteOwnedCharacterRejected() { + KoCharacter character = new KoCharacter("아기 호랑이", null, null, "url", 10, 1, 1, true); + when(characterRepository.findById(1L)).thenReturn(Optional.of(character)); + when(userCharacterRepository.existsByCharacter(character)).thenReturn(true); + + GlobalException e = assertThrows(GlobalException.class, + () -> adminStoreService.deleteCharacter(1L)); + assertEquals(ResultCode.CHARACTER_IN_USE, e.getResultCode()); + verify(characterRepository, never()).delete(any()); + } + + @DisplayName("구매자가 없으면 삭제 성공 (S3 이미지도 삭제)") + @Test + void deleteCharacter() { + KoCharacter character = new KoCharacter("아기 호랑이", null, null, "url", 10, 1, 1, true); + when(characterRepository.findById(1L)).thenReturn(Optional.of(character)); + when(userCharacterRepository.existsByCharacter(character)).thenReturn(false); + + adminStoreService.deleteCharacter(1L); + + verify(s3Util).deleteFile(eq("url"), eq(FilePath.CHARACTER)); + verify(characterRepository).delete(character); + } + + @DisplayName("수동 지급: 이미 보유 시 예외, 미보유 시 무료 지급") + @Test + void grantCharacter() { + KoCharacter character = new KoCharacter("이벤트 캐릭터", null, null, "url", 100, 1, 1, true); + ReflectionTestUtils.setField(character, "characterId", 1L); + User user = new User("tester", "tester@test.com", Role.USER, Provider.KAKAO); + ReflectionTestUtils.setField(user, "userId", 2L); + when(characterRepository.findById(1L)).thenReturn(Optional.of(character)); + when(userRepository.findById(2L)).thenReturn(Optional.of(user)); + when(userCharacterRepository.existsByUserAndCharacter(user, character)).thenReturn(true); + + GlobalException e = assertThrows(GlobalException.class, + () -> adminStoreService.grantCharacter(1L, 2L)); + assertEquals(ResultCode.ALREADY_OWNED_CHARACTER, e.getResultCode()); + verify(eventPublisher, never()).publishEvent(any()); + + when(userCharacterRepository.existsByUserAndCharacter(user, character)).thenReturn(false); + when(userCharacterRepository.saveAndFlush(any(UserCharacter.class))).thenAnswer(invocation -> { + UserCharacter userCharacter = invocation.getArgument(0); + ReflectionTestUtils.setField(userCharacter, "userCharacterId", 5L); + return userCharacter; + }); + + assertEquals(5L, adminStoreService.grantCharacter(1L, 2L).getUserCharacterId()); + assertEquals(0L, user.getPoint()); // 지급은 포인트를 건드리지 않음 + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(CharacterUnlockedEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + assertEquals(2L, eventCaptor.getValue().userId()); + assertEquals(5L, eventCaptor.getValue().userCharacterId()); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/character/service/CharacterDataSeederTest.java b/src/test/java/devkor/com/teamcback/domain/character/service/CharacterDataSeederTest.java new file mode 100644 index 00000000..b88946b7 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/character/service/CharacterDataSeederTest.java @@ -0,0 +1,92 @@ +package devkor.com.teamcback.domain.character.service; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import devkor.com.teamcback.domain.character.entity.KoCharacter; +import devkor.com.teamcback.domain.character.repository.CharacterRepository; +import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class CharacterDataSeederTest { + @InjectMocks + CharacterDataSeeder seeder; + + @Mock + CharacterRepository characterRepository; + @Mock + UserCharacterRepository userCharacterRepository; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(seeder, "imageBaseUrl", "https://s3/character"); + } + + @DisplayName("첫 실행 시 확정 캐릭터 10건 적재") + @Test + void seedOnFirstRun() { + when(characterRepository.findByName(anyString())).thenReturn(Optional.empty()); + when(characterRepository.existsByName(anyString())).thenReturn(false); + + seeder.run(null); + + verify(characterRepository, times(10)).save(any(KoCharacter.class)); + } + + @DisplayName("이미 적재된 경우 저장하지 않음 (멱등)") + @Test + void skipOnSecondRun() { + when(characterRepository.findByName(anyString())).thenReturn(Optional.empty()); + when(characterRepository.existsByName(anyString())).thenReturn(true); + + seeder.run(null); + + verify(characterRepository, never()).save(any(KoCharacter.class)); + } + + @DisplayName("동시 부팅 레이스: 중복 키 예외가 나도 나머지 시드를 계속 적재") + @Test + void continueSeedingAfterDuplicateKeyRace() { + when(characterRepository.findByName(anyString())).thenReturn(Optional.empty()); + when(characterRepository.existsByName(anyString())).thenReturn(false); + // 첫 번째 save는 다른 인스턴스가 먼저 적재해 UNIQUE 위반, 이후는 성공 + when(characterRepository.save(any(KoCharacter.class))) + .thenThrow(new org.springframework.dao.DataIntegrityViolationException("uk name")) + .thenAnswer(invocation -> invocation.getArgument(0)); + + seeder.run(null); // 예외가 전파되면 부팅 실패 → 테스트 실패 + + verify(characterRepository, times(10)).save(any(KoCharacter.class)); + } + + @DisplayName("구 플레이스홀더는 보유자가 없으면 삭제, 있으면 유지") + @Test + void removeLegacySeeds() { + KoCharacter orphanLegacy = new KoCharacter("아기 호랑이", null, null, "url", 0, 1, 1, true); + KoCharacter ownedLegacy = new KoCharacter("호랑이 대장", null, null, "url", 60, 5, 5, true); + when(characterRepository.findByName(anyString())).thenReturn(Optional.empty()); + when(characterRepository.findByName("아기 호랑이")).thenReturn(Optional.of(orphanLegacy)); + when(characterRepository.findByName("호랑이 대장")).thenReturn(Optional.of(ownedLegacy)); + when(userCharacterRepository.existsByCharacter(orphanLegacy)).thenReturn(false); + when(userCharacterRepository.existsByCharacter(ownedLegacy)).thenReturn(true); + when(characterRepository.existsByName(anyString())).thenReturn(true); // 시드 자체는 스킵 + + seeder.run(null); + + verify(characterRepository).delete(orphanLegacy); + verify(characterRepository, never()).delete(ownedLegacy); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/character/service/StoreServiceTest.java b/src/test/java/devkor/com/teamcback/domain/character/service/StoreServiceTest.java new file mode 100644 index 00000000..76914615 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/character/service/StoreServiceTest.java @@ -0,0 +1,315 @@ +package devkor.com.teamcback.domain.character.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import devkor.com.teamcback.domain.character.dto.response.GetStoreCharacterRes; +import devkor.com.teamcback.domain.character.dto.response.GetStoreRes; +import devkor.com.teamcback.domain.character.dto.response.PurchaseCharacterRes; +import devkor.com.teamcback.domain.character.entity.KoCharacter; +import devkor.com.teamcback.domain.character.entity.PurchaseStatus; +import devkor.com.teamcback.domain.character.entity.UserCharacter; +import devkor.com.teamcback.domain.character.event.CharacterUnlockedEvent; +import devkor.com.teamcback.domain.character.repository.CharacterRepository; +import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; +import devkor.com.teamcback.domain.user.entity.Provider; +import devkor.com.teamcback.domain.user.entity.Role; +import devkor.com.teamcback.domain.user.entity.User; +import devkor.com.teamcback.domain.user.repository.UserRepository; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import devkor.com.teamcback.global.response.ResultCode; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class StoreServiceTest { + @InjectMocks + StoreService storeService; + + @Mock + CharacterRepository characterRepository; + @Mock + UserCharacterRepository userCharacterRepository; + @Mock + UserRepository userRepository; + @Mock + ApplicationEventPublisher eventPublisher; + + static final Long USER_ID = 1L; + static final Long CHARACTER_ID = 10L; + + User user; + KoCharacter character; // 해금 레벨 1, 가격 10 + + @BeforeEach + void setUp() { + user = new User("tester", "tester@test.com", Role.USER, Provider.KAKAO); + ReflectionTestUtils.setField(user, "userId", USER_ID); + + character = newCharacter(CHARACTER_ID, "아기 호랑이", 10, 1, 1, true); + } + + private KoCharacter newCharacter(Long id, String name, int price, int requiredLevel, int order, boolean active) { + KoCharacter c = new KoCharacter(name, null, name + " 대사", "url", price, requiredLevel, order, active); + ReflectionTestUtils.setField(c, "characterId", id); + return c; + } + + @DisplayName("레벨 충족 + 포인트 충분이면 구매 성공") + @Test + void purchaseCharacter() { + user.addPoint(25); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(characterRepository.findById(CHARACTER_ID)).thenReturn(Optional.of(character)); + when(userCharacterRepository.existsByUserAndCharacter(user, character)).thenReturn(false); + when(userRepository.deductPoint(USER_ID, 10)).thenReturn(1); + when(userCharacterRepository.saveAndFlush(any(UserCharacter.class))) + .thenAnswer(invocation -> { + UserCharacter userCharacter = invocation.getArgument(0); + ReflectionTestUtils.setField(userCharacter, "userCharacterId", 55L); + return userCharacter; + }); + + PurchaseCharacterRes res = storeService.purchaseCharacter(USER_ID, CHARACTER_ID); + + assertEquals(CHARACTER_ID, res.getCharacterId()); + assertEquals(10, res.getPrice()); + verify(userRepository).deductPoint(USER_ID, 10); + + ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(CharacterUnlockedEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); + assertEquals(USER_ID, eventCaptor.getValue().userId()); + assertEquals(CHARACTER_ID, eventCaptor.getValue().characterId()); + assertEquals(55L, eventCaptor.getValue().userCharacterId()); + } + + @DisplayName("해금 레벨 미달이면 포인트가 충분해도 구매 불가") + @Test + void purchaseInsufficientLevel() { + KoCharacter level3Character = newCharacter(CHARACTER_ID, "청년 호랑이", 10, 3, 3, true); + user.addPoint(100); // 포인트는 충분 + // 유저는 score 0 → LEVEL1 + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(characterRepository.findById(CHARACTER_ID)).thenReturn(Optional.of(level3Character)); + when(userCharacterRepository.existsByUserAndCharacter(user, level3Character)).thenReturn(false); + + GlobalException e = assertThrows(GlobalException.class, + () -> storeService.purchaseCharacter(USER_ID, CHARACTER_ID)); + assertEquals(ResultCode.INSUFFICIENT_LEVEL, e.getResultCode()); + verify(userRepository, never()).deductPoint(any(), org.mockito.ArgumentMatchers.anyInt()); + + // 레벨 3 도달 시 구매 가능 + user.updateScore(20L, false); // LEVEL3 + when(userRepository.deductPoint(USER_ID, 10)).thenReturn(1); + when(userCharacterRepository.saveAndFlush(any(UserCharacter.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + assertEquals(CHARACTER_ID, storeService.purchaseCharacter(USER_ID, CHARACTER_ID).getCharacterId()); + } + + @DisplayName("포인트 부족 시 구매 실패") + @Test + void purchaseInsufficientPoint() { + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(characterRepository.findById(CHARACTER_ID)).thenReturn(Optional.of(character)); + when(userCharacterRepository.existsByUserAndCharacter(user, character)).thenReturn(false); + when(userRepository.deductPoint(USER_ID, 10)).thenReturn(0); // 잔액 부족 + + GlobalException e = assertThrows(GlobalException.class, + () -> storeService.purchaseCharacter(USER_ID, CHARACTER_ID)); + assertEquals(ResultCode.INSUFFICIENT_POINT, e.getResultCode()); + verify(userCharacterRepository, never()).saveAndFlush(any()); + } + + @DisplayName("이미 보유한 캐릭터 구매 시 예외 (차감 없음)") + @Test + void purchaseAlreadyOwned() { + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(characterRepository.findById(CHARACTER_ID)).thenReturn(Optional.of(character)); + when(userCharacterRepository.existsByUserAndCharacter(user, character)).thenReturn(true); + + GlobalException e = assertThrows(GlobalException.class, + () -> storeService.purchaseCharacter(USER_ID, CHARACTER_ID)); + assertEquals(ResultCode.ALREADY_OWNED_CHARACTER, e.getResultCode()); + verify(userRepository, never()).deductPoint(any(), org.mockito.ArgumentMatchers.anyInt()); + verify(eventPublisher, never()).publishEvent(any()); + } + + @DisplayName("비활성 캐릭터는 구매 불가") + @Test + void purchaseInactiveRejected() { + KoCharacter inactive = newCharacter(CHARACTER_ID, "숨김 캐릭터", 10, 1, 9, false); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(characterRepository.findById(CHARACTER_ID)).thenReturn(Optional.of(inactive)); + + GlobalException e = assertThrows(GlobalException.class, + () -> storeService.purchaseCharacter(USER_ID, CHARACTER_ID)); + assertEquals(ResultCode.INACTIVE_CHARACTER, e.getResultCode()); + } + + @DisplayName("동시 중복 구매 시 제약 위반을 이미 보유로 매핑 (롤백으로 차감 복구)") + @Test + void purchaseRaceMappedToAlreadyOwned() { + user.addPoint(25); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(characterRepository.findById(CHARACTER_ID)).thenReturn(Optional.of(character)); + when(userCharacterRepository.existsByUserAndCharacter(user, character)).thenReturn(false); + when(userRepository.deductPoint(USER_ID, 10)).thenReturn(1); + when(userCharacterRepository.saveAndFlush(any(UserCharacter.class))) + .thenThrow(new DataIntegrityViolationException("uk_user_character")); + + GlobalException e = assertThrows(GlobalException.class, + () -> storeService.purchaseCharacter(USER_ID, CHARACTER_ID)); + assertEquals(ResultCode.ALREADY_OWNED_CHARACTER, e.getResultCode()); + verify(eventPublisher, never()).publishEvent(any()); + } + + @DisplayName("미보유 캐릭터 장착 시 예외, 보유 캐릭터는 장착/해제 성공") + @Test + void equipCharacter() { + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(characterRepository.findById(CHARACTER_ID)).thenReturn(Optional.of(character)); + when(userCharacterRepository.existsByUserAndCharacter(user, character)).thenReturn(false); + + GlobalException e = assertThrows(GlobalException.class, + () -> storeService.equipCharacter(USER_ID, CHARACTER_ID)); + assertEquals(ResultCode.NOT_OWNED_CHARACTER, e.getResultCode()); + + when(userCharacterRepository.existsByUserAndCharacter(user, character)).thenReturn(true); + storeService.equipCharacter(USER_ID, CHARACTER_ID); + assertEquals(CHARACTER_ID, user.getEquippedCharacterId()); + + storeService.unequipCharacter(USER_ID); + assertNull(user.getEquippedCharacterId()); + } + + @DisplayName("스토어 목록: 4단계 상태 판정 (보유/구매가능/미해금/포인트부족)") + @Test + void getStoreStatuses() { + user.updateScore(5L, false); // LEVEL2 + user.addPoint(10); + user.updateEquippedCharacter(CHARACTER_ID); + + KoCharacter ownedCharacter = character; // 보유 + 장착 + KoCharacter purchasableCharacter = newCharacter(11L, "학생 호랑이", 10, 2, 2, true); // 레벨·포인트 충족 + KoCharacter lockedCharacter = newCharacter(12L, "청년 호랑이", 5, 3, 3, true); // 레벨 미달 (포인트는 충분해도 LOCKED) + KoCharacter expensiveCharacter = newCharacter(13L, "비싼 호랑이", 999, 1, 4, true); // 레벨 충족, 포인트 부족 + + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(characterRepository.findAllByIsActiveTrueOrderByDisplayOrderAsc()) + .thenReturn(List.of(ownedCharacter, purchasableCharacter, lockedCharacter, expensiveCharacter)); + when(userCharacterRepository.findAllByUser(user)) + .thenReturn(List.of(new UserCharacter(user, ownedCharacter))); + + GetStoreRes res = storeService.getStore(USER_ID); + List list = res.getCharacterList(); + + assertEquals(10L, res.getPoint()); + assertEquals(PurchaseStatus.OWNED, statusOf(list, CHARACTER_ID)); + assertEquals(PurchaseStatus.PURCHASABLE, statusOf(list, 11L)); + assertEquals(PurchaseStatus.LOCKED, statusOf(list, 12L)); + assertEquals(PurchaseStatus.NOT_ENOUGH_POINT, statusOf(list, 13L)); + } + + @DisplayName("스토어 정렬: 보유(레벨→포인트) → 구매가능(포인트) → 미해금(레벨) → 포인트부족(포인트)") + @Test + void getStoreSorting() { + user.updateScore(5L, false); // LEVEL2 + user.addPoint(20); + + // displayOrder는 전부 역순(9~1)으로 줘서 정렬이 displayOrder가 아닌 스펙 기준임을 증명 + KoCharacter ownedHighLevel = newCharacter(21L, "보유-레벨2", 5, 2, 9, true); + KoCharacter ownedLowLevel = newCharacter(22L, "보유-레벨1", 10, 1, 8, true); + KoCharacter purchasableExpensive = newCharacter(23L, "구매가능-20p", 20, 1, 7, true); + KoCharacter purchasableCheap = newCharacter(24L, "구매가능-5p", 5, 2, 6, true); + KoCharacter lockedLevel5 = newCharacter(25L, "미해금-레벨5", 0, 5, 5, true); + KoCharacter lockedLevel3 = newCharacter(26L, "미해금-레벨3", 0, 3, 4, true); + KoCharacter poorExpensive = newCharacter(27L, "포인트부족-99p", 99, 1, 3, true); + KoCharacter poorCheap = newCharacter(28L, "포인트부족-30p", 30, 2, 2, true); + + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(characterRepository.findAllByIsActiveTrueOrderByDisplayOrderAsc()) + .thenReturn(List.of(ownedHighLevel, ownedLowLevel, purchasableExpensive, purchasableCheap, + lockedLevel5, lockedLevel3, poorExpensive, poorCheap)); + when(userCharacterRepository.findAllByUser(user)) + .thenReturn(List.of(new UserCharacter(user, ownedHighLevel), new UserCharacter(user, ownedLowLevel))); + + List list = storeService.getStore(USER_ID).getCharacterList(); + List orderedIds = list.stream().map(GetStoreCharacterRes::getCharacterId).toList(); + + assertEquals(List.of( + 22L, 21L, // 1순위 보유: 레벨1(가격10) → 레벨2(가격5) + 24L, 23L, // 2순위 구매가능: 5p → 20p + 26L, 25L, // 3순위 미해금: 레벨3 → 레벨5 + 28L, 27L // 4순위 포인트부족: 30p → 99p + ), orderedIds); + } + + @DisplayName("지급받아 보유 중인 캐릭터는 레벨 미달이어도 '이미 보유' 응답 (검증 순서)") + @Test + void purchaseOwnedTakesPrecedenceOverLevel() { + // 운영자 지급으로 레벨 5 캐릭터를 보유한 레벨 1 사용자 + KoCharacter grantedCharacter = newCharacter(CHARACTER_ID, "어른호랑이", 30, 5, 5, true); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(characterRepository.findById(CHARACTER_ID)).thenReturn(Optional.of(grantedCharacter)); + when(userCharacterRepository.existsByUserAndCharacter(user, grantedCharacter)).thenReturn(true); + + GlobalException e = assertThrows(GlobalException.class, + () -> storeService.purchaseCharacter(USER_ID, CHARACTER_ID)); + assertEquals(ResultCode.ALREADY_OWNED_CHARACTER, e.getResultCode()); // INSUFFICIENT_LEVEL이 아니어야 함 + } + + @DisplayName("가격 0 캐릭터는 포인트 0이어도 구매 가능 상태 (경계값)") + @Test + void freeCharacterPurchasableWithZeroPoint() { + // 신규 사용자: score 0, point 0, LEVEL1 + KoCharacter freeCharacter = newCharacter(CHARACTER_ID, "애기호랑이", 0, 1, 1, true); + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(characterRepository.findAllByIsActiveTrueOrderByDisplayOrderAsc()).thenReturn(List.of(freeCharacter)); + when(userCharacterRepository.findAllByUser(user)).thenReturn(List.of()); + + GetStoreRes res = storeService.getStore(USER_ID); + + assertEquals(PurchaseStatus.PURCHASABLE, res.getCharacterList().get(0).getStatus()); + } + + @DisplayName("내 보유 캐릭터: 포인트·대표 캐릭터·장착 플래그·대사 반환") + @Test + void getMyCharacters() { + user.addPoint(7); + user.updateEquippedCharacter(CHARACTER_ID); + KoCharacter otherCharacter = newCharacter(11L, "꼬마호랑이", 15, 2, 2, true); + + when(userRepository.findById(USER_ID)).thenReturn(Optional.of(user)); + when(userCharacterRepository.findAllByUser(user)).thenReturn( + List.of(new UserCharacter(user, character), new UserCharacter(user, otherCharacter))); + + var res = storeService.getMyCharacters(USER_ID); + + assertEquals(7L, res.getPoint()); + assertEquals(CHARACTER_ID, res.getEquippedCharacterId()); + assertEquals(2, res.getCharacterList().size()); + assertEquals(true, res.getCharacterList().get(0).isEquipped()); // 장착한 캐릭터 + assertEquals(false, res.getCharacterList().get(1).isEquipped()); // 미장착 보유 캐릭터 + assertEquals("아기 호랑이 대사", res.getCharacterList().get(0).getQuote()); + } + + private PurchaseStatus statusOf(List list, Long characterId) { + return list.stream().filter(c -> c.getCharacterId().equals(characterId)).findFirst().orElseThrow().getStatus(); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/PushNotificationPipelineIntegrationTest.java b/src/test/java/devkor/com/teamcback/domain/notification/PushNotificationPipelineIntegrationTest.java new file mode 100644 index 00000000..f312a787 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/PushNotificationPipelineIntegrationTest.java @@ -0,0 +1,344 @@ +package devkor.com.teamcback.domain.notification; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import devkor.com.teamcback.domain.notification.dto.request.NotificationTestReq; +import devkor.com.teamcback.domain.notification.entity.PushDispatch; +import devkor.com.teamcback.domain.notification.entity.PushInstallation; +import devkor.com.teamcback.domain.notification.entity.PushMessage; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushDispatchStatus; +import devkor.com.teamcback.domain.notification.entity.type.PushMessageStatus; +import devkor.com.teamcback.domain.notification.repository.PushDispatchRepository; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.domain.notification.repository.PushMessageRepository; +import devkor.com.teamcback.domain.notification.service.NotificationTestService; +import devkor.com.teamcback.domain.notification.service.PushMessageDispatchWorker; +import devkor.com.teamcback.domain.notification.service.PushReceiptWorker; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.ConcurrentLinkedQueue; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.util.ReflectionTestUtils; +import org.redisson.api.RedissonClient; + +import static org.assertj.core.api.Assertions.assertThat; + +@ActiveProfiles("test") +@SpringBootTest(properties = { + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.url=jdbc:h2:mem:push_pipeline;MODE=MySQL;DATABASE_TO_LOWER=TRUE;NON_KEYWORDS=YEAR,END;DB_CLOSE_DELAY=-1", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "spring.sql.init.mode=never", + "spring.cache.type=simple", + "spring.data.redis.host=localhost", + "spring.data.redis.port=6379", + "spring.data.redis.password=test", + "push.worker.enabled=false", + "push.receipt-worker.enabled=false", + "jwt.secret.key=MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=", + "jwt.admin.token=test-admin-token", + "jwt.social.kakao.iss=test-kakao-iss", + "jwt.social.kakao.aud=test-kakao-aud", + "jwt.social.google.iss=test-google-iss", + "jwt.social.google.aud=test-google-aud", + "jwt.social.apple.iss=test-apple-iss", + "jwt.social.apple.aud=test-apple-aud", + "jwt.social.apple.dev-aud=test-apple-dev-aud", + "metrics.environment=test", + "staff.emails=test@example.com", + "cloud.aws.s3.bucket=test-bucket", + "cloud.aws.credentials.access-key=test-access-key", + "cloud.aws.credentials.secret-key=test-secret-key", + "cloud.aws.region.static=ap-northeast-2", + "date.api.holiday.end-point=http://localhost", + "date.api.holiday.encoded-key=test-encoded-key", + "date.api.holiday.decoded-key=test-decoded-key", + "spring.mail.host=localhost", + "management.health.mail.enabled=false" +}) +class PushNotificationPipelineIntegrationTest { + + private static final String EXPO_PUSH_TOKEN = "ExponentPushToken[pipeline-test-token]"; + private static final String TICKET_ID = "ticket-pipeline-1"; + private static final MockExpoServer EXPO_SERVER = new MockExpoServer(); + + @Autowired + private NotificationTestService notificationTestService; + + @Autowired + private PushMessageDispatchWorker pushMessageDispatchWorker; + + @Autowired + private PushReceiptWorker pushReceiptWorker; + + @Autowired + private PushInstallationRepository pushInstallationRepository; + + @Autowired + private PushDispatchRepository pushDispatchRepository; + + @Autowired + private PushMessageRepository pushMessageRepository; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private RedissonClient redissonClient; + + @DynamicPropertySource + static void expoProperties(DynamicPropertyRegistry registry) { + EXPO_SERVER.start(); + registry.add("push.expo.base-url", EXPO_SERVER::baseUrl); + } + + @AfterAll + static void stopExpoServer() { + EXPO_SERVER.stop(); + } + + @BeforeEach + void setUp() { + EXPO_SERVER.reset(); + pushMessageRepository.deleteAll(); + pushDispatchRepository.deleteAll(); + pushInstallationRepository.deleteAll(); + } + + @Test + void testNotificationPipelineQueuesSendsReceiptsAndCompletesDispatch() throws Exception { + PushInstallation installation = pushInstallationRepository.save(new PushInstallation( + 1L, + "install-pipeline", + EXPO_PUSH_TOKEN, + AppVariant.DEV + )); + + notificationTestService.sendTest( + 1L, + UUID.randomUUID().toString(), + new NotificationTestReq(1, installation.getInstallationId()) + ); + + List dispatches = pushDispatchRepository.findAll(); + List messages = pushMessageRepository.findAll(); + assertThat(dispatches).hasSize(1); + assertThat(messages).hasSize(1); + + PushDispatch dispatch = dispatches.get(0); + PushMessage queuedMessage = messages.get(0); + assertThat(dispatch.getRecipientCount()).isEqualTo(1); + assertThat(queuedMessage.getStatus()).isEqualTo(PushMessageStatus.QUEUED); + assertThat(EXPO_SERVER.requests()).isEmpty(); + + EXPO_SERVER.enqueueJson(200, """ + {"data":[{"status":"ok","id":"ticket-pipeline-1"}]} + """); + pushMessageDispatchWorker.dispatchPending(); + + PushMessage receiptPendingMessage = pushMessageRepository.findById(queuedMessage.getPushMessageId()).orElseThrow(); + assertThat(receiptPendingMessage.getStatus()).isEqualTo(PushMessageStatus.RECEIPT_PENDING); + assertThat(receiptPendingMessage.getExpoTicketId()).isEqualTo(TICKET_ID); + assertThat(receiptPendingMessage.getReceiptAvailableAt()).isNotNull(); + + RecordedRequest sendRequest = EXPO_SERVER.takeOnlyRequest(); + assertThat(sendRequest.path()).isEqualTo("/send"); + JsonNode sendBody = objectMapper.readTree(sendRequest.body()); + JsonNode firstSend = sendBody.get(0); + assertThat(firstSend.get("to").asText()).isEqualTo(EXPO_PUSH_TOKEN); + assertThat(firstSend.at("/data/action/type").asText()).isEqualTo(PushActionType.TEST.name()); + assertThat(firstSend.at("/data/notificationId").asText()) + .isEqualTo(String.valueOf(receiptPendingMessage.getPushMessageId())); + + ReflectionTestUtils.setField( + receiptPendingMessage, + "receiptAvailableAt", + LocalDateTime.now().minusMinutes(1) + ); + pushMessageRepository.saveAndFlush(receiptPendingMessage); + + EXPO_SERVER.reset(); + EXPO_SERVER.enqueueJson(200, """ + {"data":{"ticket-pipeline-1":{"status":"ok"}}} + """); + pushReceiptWorker.checkPendingReceipts(); + + PushMessage deliveredMessage = pushMessageRepository.findById(queuedMessage.getPushMessageId()).orElseThrow(); + PushDispatch completedDispatch = pushDispatchRepository.findById(dispatch.getPushDispatchId()).orElseThrow(); + assertThat(deliveredMessage.getStatus()).isEqualTo(PushMessageStatus.DELIVERED); + assertThat(deliveredMessage.getReceiptStatus()).isEqualTo("ok"); + assertThat(deliveredMessage.getReceiptCheckedAt()).isNotNull(); + assertThat(completedDispatch.getStatus()).isEqualTo(PushDispatchStatus.COMPLETED); + assertThat(pushMessageRepository.findById(deliveredMessage.getPushMessageId())).isPresent(); + assertThat(pushDispatchRepository.findById(completedDispatch.getPushDispatchId())).isPresent(); + + RecordedRequest receiptRequest = EXPO_SERVER.takeOnlyRequest(); + assertThat(receiptRequest.path()).isEqualTo("/getReceipts"); + JsonNode receiptBody = objectMapper.readTree(receiptRequest.body()); + JsonNode ids = receiptBody.get("ids"); + assertThat(ids) + .as("receipt request body: " + receiptRequest.body()) + .isNotNull(); + assertThat(ids.isArray()).isTrue(); + assertThat(ids.get(0).asText()).isEqualTo(TICKET_ID); + } + + @Test + void receiptDeviceNotRegisteredFailsMessageAndDeactivatesInstallation() { + PushInstallation installation = pushInstallationRepository.save(new PushInstallation( + 1L, + "install-device-not-registered", + EXPO_PUSH_TOKEN, + AppVariant.DEV + )); + + notificationTestService.sendTest( + 1L, + UUID.randomUUID().toString(), + new NotificationTestReq(1, installation.getInstallationId()) + ); + + EXPO_SERVER.enqueueJson(200, """ + {"data":[{"status":"ok","id":"ticket-pipeline-1"}]} + """); + pushMessageDispatchWorker.dispatchPending(); + + PushMessage receiptPendingMessage = pushMessageRepository.findAll().get(0); + ReflectionTestUtils.setField( + receiptPendingMessage, + "receiptAvailableAt", + LocalDateTime.now().minusMinutes(1) + ); + pushMessageRepository.saveAndFlush(receiptPendingMessage); + + EXPO_SERVER.reset(); + EXPO_SERVER.enqueueJson(200, """ + {"data":{"ticket-pipeline-1":{"status":"error","details":{"error":"DeviceNotRegistered"}}}} + """); + pushReceiptWorker.checkPendingReceipts(); + + PushMessage failedMessage = pushMessageRepository.findById(receiptPendingMessage.getPushMessageId()).orElseThrow(); + PushInstallation deactivatedInstallation = pushInstallationRepository.findById(installation.getPushInstallationId()) + .orElseThrow(); + PushDispatch failedDispatch = pushDispatchRepository.findAll().get(0); + + assertThat(failedMessage.getStatus()).isEqualTo(PushMessageStatus.FAILED); + assertThat(failedMessage.getReceiptStatus()).isEqualTo("error"); + assertThat(failedMessage.getReceiptError()).isEqualTo("DeviceNotRegistered"); + assertThat(deactivatedInstallation.isActive()).isFalse(); + assertThat(failedDispatch.getStatus()).isEqualTo(PushDispatchStatus.FAILED); + } + + private record RecordedRequest(String path, String body) { + } + + private static final class MockExpoServer { + + private final ConcurrentLinkedQueue responses = new ConcurrentLinkedQueue<>(); + private final List requests = new ArrayList<>(); + private HttpServer server; + + void start() { + if (server != null) { + return; + } + + try { + server = HttpServer.create(new InetSocketAddress("localhost", 0), 0); + } catch (IOException e) { + throw new IllegalStateException(e); + } + + server.createContext("/", this::handle); + server.start(); + } + + void stop() { + if (server != null) { + server.stop(0); + } + } + + String baseUrl() { + return "http://localhost:" + server.getAddress().getPort(); + } + + void reset() { + responses.clear(); + synchronized (requests) { + requests.clear(); + } + } + + void enqueueJson( + int status, + String body + ) { + responses.add(status + "\n" + body); + } + + List requests() { + synchronized (requests) { + return List.copyOf(requests); + } + } + + RecordedRequest takeOnlyRequest() { + List snapshot = requests(); + assertThat(snapshot).hasSize(1); + return snapshot.get(0); + } + + private void handle(HttpExchange exchange) throws IOException { + String requestBody = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + synchronized (requests) { + requests.add(new RecordedRequest(exchange.getRequestURI().getPath(), requestBody)); + } + + String rawResponse = responses.poll(); + if (rawResponse == null) { + write(exchange, 500, "{\"errors\":[{\"message\":\"missing mock response\"}]}"); + return; + } + + int delimiter = rawResponse.indexOf('\n'); + int status = Integer.parseInt(rawResponse.substring(0, delimiter)); + String body = rawResponse.substring(delimiter + 1); + write(exchange, status, body); + } + + private void write( + HttpExchange exchange, + int status, + String body + ) throws IOException { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(status, bytes.length); + try (OutputStream responseBody = exchange.getResponseBody()) { + responseBody.write(bytes); + } + } + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/controller/AdminNotificationControllerTest.java b/src/test/java/devkor/com/teamcback/domain/notification/controller/AdminNotificationControllerTest.java new file mode 100644 index 00000000..55d5c870 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/controller/AdminNotificationControllerTest.java @@ -0,0 +1,66 @@ +package devkor.com.teamcback.domain.notification.controller; + +import devkor.com.teamcback.domain.notification.dto.response.AdminPushEventFlagRes; +import devkor.com.teamcback.domain.notification.entity.type.PushEventType; +import devkor.com.teamcback.domain.notification.service.AdminNotificationService; +import devkor.com.teamcback.domain.notification.service.PushEventFlagService; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(MockitoExtension.class) +class AdminNotificationControllerTest { + + @Mock + private AdminNotificationService adminNotificationService; + + @Mock + private PushEventFlagService pushEventFlagService; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + AdminNotificationController controller = new AdminNotificationController( + adminNotificationService, + pushEventFlagService + ); + mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + } + + @Test + void updatedEventFlagIsReflectedInAdminApiQueryResult() throws Exception { + when(pushEventFlagService.updateFlag(PushEventType.REPORT, true)) + .thenReturn(new AdminPushEventFlagRes(PushEventType.REPORT, true)); + when(pushEventFlagService.getFlags()) + .thenReturn(List.of( + new AdminPushEventFlagRes(PushEventType.CROWD, false), + new AdminPushEventFlagRes(PushEventType.REPORT, true), + new AdminPushEventFlagRes(PushEventType.CHARACTER, false) + )); + + mockMvc.perform(patch("/api/admin/notifications/event-flags/REPORT") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"enabled\":true}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.eventType").value("REPORT")) + .andExpect(jsonPath("$.data.enabled").value(true)); + + mockMvc.perform(get("/api/admin/notifications/event-flags")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[1].eventType").value("REPORT")) + .andExpect(jsonPath("$.data[1].enabled").value(true)); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/factory/PushPayloadFactoryTest.java b/src/test/java/devkor/com/teamcback/domain/notification/factory/PushPayloadFactoryTest.java new file mode 100644 index 00000000..ce5248cb --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/factory/PushPayloadFactoryTest.java @@ -0,0 +1,36 @@ +package devkor.com.teamcback.domain.notification.factory; + +import com.fasterxml.jackson.databind.ObjectMapper; +import devkor.com.teamcback.domain.notification.dto.payload.PushPayload; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.domain.notification.validation.PushActionValidator; +import java.util.Map; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class PushPayloadFactoryTest { + + private final PushPayloadFactory factory = new PushPayloadFactory( + new PushActionValidator(), + new ObjectMapper() + ); + + @Test + void includesTargetAppVariantInDeepLinkPayload() { + PushPayload payload = factory.create( + "notification-1", + "title", + "body", + PushMode.ACTUAL, + AppVariant.PREVIEW, + PushActionType.HOME, + Map.of() + ); + + assertThat(payload.data().appVariant()).isEqualTo("preview"); + assertThat(payload.data().action().type()).isEqualTo("HOME"); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListenerTest.java b/src/test/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListenerTest.java new file mode 100644 index 00000000..a04bd2e6 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/listener/CharacterUnlockedPushEventListenerTest.java @@ -0,0 +1,123 @@ +package devkor.com.teamcback.domain.notification.listener; + +import devkor.com.teamcback.domain.character.event.CharacterUnlockedEvent; +import devkor.com.teamcback.domain.notification.dto.request.PushDispatchCommand; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushEventType; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.domain.notification.service.PushDispatchService; +import devkor.com.teamcback.domain.notification.service.PushEventFlagService; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CharacterUnlockedPushEventListenerTest { + + @Mock + private PushInstallationRepository pushInstallationRepository; + + @Mock + private PushDispatchService pushDispatchService; + + @Mock + private PushEventFlagService pushEventFlagService; + + private CharacterUnlockedPushEventListener listener; + + @BeforeEach + void setUp() { + listener = new CharacterUnlockedPushEventListener( + pushInstallationRepository, + pushDispatchService, + pushEventFlagService + ); + } + + @Test + void createsCharacterStoreDispatch() { + when(pushEventFlagService.isEnabled(PushEventType.CHARACTER)).thenReturn(true); + when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(7L, AppVariant.PRODUCTION)) + .thenReturn(true); + + listener.handle(new CharacterUnlockedEvent(7L, 4L, 44L, "아기 호랑이")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PushDispatchCommand.class); + verify(pushDispatchService).enqueue(captor.capture()); + PushDispatchCommand command = captor.getValue(); + assertThat(command.targetType()).isEqualTo(PushTargetType.USER); + assertThat(command.actionType()).isEqualTo(PushActionType.CHARACTER_STORE); + assertThat(command.actionParams()).isEmpty(); + assertThat(command.title()).isEqualTo("새 캐릭터가 기다리고 있어요!"); + assertThat(command.body()).isEqualTo("아기 호랑이을 만나러 가볼까요?"); + assertThat(command.idempotencyKey()).isEqualTo("character-unlock:7:4:44:production"); + } + + @Test + void createsSeparateDevAndProductionDispatches() { + when(pushEventFlagService.isEnabled(PushEventType.CHARACTER)).thenReturn(true); + when(pushEventFlagService.getTargetAppVariants()).thenReturn(List.of(AppVariant.DEV, AppVariant.PRODUCTION)); + when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(7L, AppVariant.DEV)) + .thenReturn(true); + when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(7L, AppVariant.PRODUCTION)) + .thenReturn(true); + + listener.handle(new CharacterUnlockedEvent(7L, 4L, 44L, "아기 호랑이")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PushDispatchCommand.class); + verify(pushDispatchService, org.mockito.Mockito.times(2)).enqueue(captor.capture()); + assertThat(captor.getAllValues()) + .extracting(PushDispatchCommand::appVariant) + .containsExactly(AppVariant.DEV, AppVariant.PRODUCTION); + assertThat(captor.getAllValues()) + .extracting(PushDispatchCommand::idempotencyKey) + .containsExactly( + "character-unlock:7:4:44:dev", + "character-unlock:7:4:44:production" + ); + } + + @Test + void usesSafeBodyWhenCharacterNameIsBlank() { + when(pushEventFlagService.isEnabled(PushEventType.CHARACTER)).thenReturn(true); + when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(7L, AppVariant.PRODUCTION)) + .thenReturn(true); + + listener.handle(new CharacterUnlockedEvent(7L, 4L, 44L, " ")); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PushDispatchCommand.class); + verify(pushDispatchService).enqueue(captor.capture()); + assertThat(captor.getValue().body()).isEqualTo("새로운 캐릭터을 만나러 가볼까요?"); + } + + @Test + void doesNotCreateDispatchWhenFeatureFlagIsFalse() { + when(pushEventFlagService.isEnabled(PushEventType.CHARACTER)).thenReturn(false); + + listener.handle(new CharacterUnlockedEvent(7L, 4L, 44L, "아기 호랑이")); + + verify(pushDispatchService, never()).enqueue(org.mockito.ArgumentMatchers.any()); + } + + @Test + void doesNotCreateDispatchWhenUserHasNoProductionInstallation() { + when(pushEventFlagService.isEnabled(PushEventType.CHARACTER)).thenReturn(true); + when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(7L, AppVariant.PRODUCTION)) + .thenReturn(false); + + listener.handle(new CharacterUnlockedEvent(7L, 4L, 44L, "아기 호랑이")); + + verify(pushDispatchService, never()).enqueue(org.mockito.ArgumentMatchers.any()); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListenerTest.java b/src/test/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListenerTest.java new file mode 100644 index 00000000..8f0f3bf5 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/listener/CrowdVacantPushEventListenerTest.java @@ -0,0 +1,152 @@ +package devkor.com.teamcback.domain.notification.listener; + +import devkor.com.teamcback.domain.ble.event.PlaceBecameVacantEvent; +import devkor.com.teamcback.domain.bookmark.repository.CategoryRepository; +import devkor.com.teamcback.domain.building.entity.Building; +import devkor.com.teamcback.domain.common.LocationType; +import devkor.com.teamcback.domain.notification.dto.request.PushDispatchCommand; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushEventType; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.domain.notification.service.PushDispatchService; +import devkor.com.teamcback.domain.notification.service.PushEventFlagService; +import devkor.com.teamcback.domain.place.entity.Place; +import devkor.com.teamcback.domain.place.repository.PlaceRepository; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CrowdVacantPushEventListenerTest { + + @Mock + private PlaceRepository placeRepository; + + @Mock + private CategoryRepository categoryRepository; + + @Mock + private PushInstallationRepository pushInstallationRepository; + + @Mock + private PushDispatchService pushDispatchService; + + @Mock + private PushEventFlagService pushEventFlagService; + + private CrowdVacantPushEventListener listener; + + @BeforeEach + void setUp() { + listener = new CrowdVacantPushEventListener( + placeRepository, + categoryRepository, + pushInstallationRepository, + pushDispatchService, + pushEventFlagService + ); + } + + @Test + void createsUserDispatchesForDistinctFavoriteUsers() { + when(pushEventFlagService.isEnabled(PushEventType.CROWD)).thenReturn(true); + when(placeRepository.findById(10L)).thenReturn(Optional.of(place("신공학관", "라운지"))); + when(categoryRepository.findDistinctUserIdsByLocationTypeAndLocationId(LocationType.PLACE, 10L)) + .thenReturn(List.of(1L, 1L, 2L)); + when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(1L, AppVariant.PRODUCTION)) + .thenReturn(true); + when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(2L, AppVariant.PRODUCTION)) + .thenReturn(true); + + listener.handle(event()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PushDispatchCommand.class); + verify(pushDispatchService, times(2)).enqueue(captor.capture()); + + PushDispatchCommand first = captor.getAllValues().get(0); + assertThat(first.targetType()).isEqualTo(PushTargetType.USER); + assertThat(first.targetValue()).isEqualTo("1"); + assertThat(first.mode()).isEqualTo(PushMode.ACTUAL); + assertThat(first.appVariant()).isEqualTo(AppVariant.PRODUCTION); + assertThat(first.actionType()).isEqualTo(PushActionType.PLACE_DETAIL); + assertThat(first.actionParams()).containsEntry("placeId", 10L); + assertThat(first.title()).isEqualTo("기다리던 자리가 생겼어요!"); + assertThat(first.body()).isEqualTo("신공학관 라운지이 한산해요. 방문하기 전 현황을 확인해보세요."); + assertThat(first.body()).doesNotContain("null"); + assertThat(first.idempotencyKey()).isEqualTo("crowd-vacant:10:1:99:production"); + } + + @Test + void doesNotCreateDispatchWhenNoFavoriteUsersExist() { + when(pushEventFlagService.isEnabled(PushEventType.CROWD)).thenReturn(true); + when(placeRepository.findById(10L)).thenReturn(Optional.of(place("신공학관", "라운지"))); + when(categoryRepository.findDistinctUserIdsByLocationTypeAndLocationId(LocationType.PLACE, 10L)) + .thenReturn(List.of()); + + listener.handle(event()); + + verify(pushDispatchService, never()).enqueue(org.mockito.ArgumentMatchers.any()); + } + + @Test + void doesNotCreateDispatchWhenFeatureFlagIsFalse() { + when(pushEventFlagService.isEnabled(PushEventType.CROWD)).thenReturn(false); + + listener.handle(event()); + + verify(pushDispatchService, never()).enqueue(org.mockito.ArgumentMatchers.any()); + verify(placeRepository, never()).findById(org.mockito.ArgumentMatchers.any()); + } + + @Test + void skipsUsersWithoutProductionInstallation() { + when(pushEventFlagService.isEnabled(PushEventType.CROWD)).thenReturn(true); + when(placeRepository.findById(10L)).thenReturn(Optional.of(place(null, "라운지"))); + when(categoryRepository.findDistinctUserIdsByLocationTypeAndLocationId(LocationType.PLACE, 10L)) + .thenReturn(List.of(1L)); + when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(1L, AppVariant.PRODUCTION)) + .thenReturn(false); + + listener.handle(event()); + + verify(pushDispatchService, never()).enqueue(org.mockito.ArgumentMatchers.any()); + } + + private PlaceBecameVacantEvent event() { + return new PlaceBecameVacantEvent( + 10L, + 99L, + LocalDateTime.parse("2026-08-05T10:00:00") + ); + } + + private Place place( + String buildingName, + String placeName + ) { + Building building = new Building(); + ReflectionTestUtils.setField(building, "name", buildingName); + + Place place = new Place(); + ReflectionTestUtils.setField(place, "id", 10L); + place.setBuilding(building); + place.setName(placeName); + return place; + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/listener/DomainPushEventListenerAnnotationTest.java b/src/test/java/devkor/com/teamcback/domain/notification/listener/DomainPushEventListenerAnnotationTest.java new file mode 100644 index 00000000..baf20a3a --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/listener/DomainPushEventListenerAnnotationTest.java @@ -0,0 +1,39 @@ +package devkor.com.teamcback.domain.notification.listener; + +import devkor.com.teamcback.domain.ble.event.PlaceBecameVacantEvent; +import devkor.com.teamcback.domain.character.event.CharacterUnlockedEvent; +import devkor.com.teamcback.domain.report.event.ReportResolvedEvent; +import org.junit.jupiter.api.Test; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import static org.assertj.core.api.Assertions.assertThat; + +class DomainPushEventListenerAnnotationTest { + + @Test + void listenersRunAfterCommit() throws NoSuchMethodException { + assertAfterCommit(CrowdVacantPushEventListener.class, PlaceBecameVacantEvent.class); + assertAfterCommit(ReportResolvedPushEventListener.class, ReportResolvedEvent.class); + assertAfterCommit(CharacterUnlockedPushEventListener.class, CharacterUnlockedEvent.class); + } + + private void assertAfterCommit( + Class listenerClass, + Class eventClass + ) throws NoSuchMethodException { + TransactionalEventListener annotation = listenerClass + .getDeclaredMethod("handle", eventClass) + .getAnnotation(TransactionalEventListener.class); + Transactional transactional = listenerClass + .getDeclaredMethod("handle", eventClass) + .getAnnotation(Transactional.class); + + assertThat(annotation).isNotNull(); + assertThat(annotation.phase()).isEqualTo(TransactionPhase.AFTER_COMMIT); + assertThat(transactional).isNotNull(); + assertThat(transactional.propagation()).isEqualTo(Propagation.REQUIRES_NEW); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListenerTest.java b/src/test/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListenerTest.java new file mode 100644 index 00000000..fce0fc11 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/listener/ReportResolvedPushEventListenerTest.java @@ -0,0 +1,97 @@ +package devkor.com.teamcback.domain.notification.listener; + +import devkor.com.teamcback.domain.notification.dto.request.PushDispatchCommand; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushEventType; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.domain.notification.service.PushDispatchService; +import devkor.com.teamcback.domain.notification.service.PushEventFlagService; +import devkor.com.teamcback.domain.report.entity.ReportStatus; +import devkor.com.teamcback.domain.report.event.ReportResolvedEvent; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ReportResolvedPushEventListenerTest { + + @Mock + private PushInstallationRepository pushInstallationRepository; + + @Mock + private PushDispatchService pushDispatchService; + + @Mock + private PushEventFlagService pushEventFlagService; + + private ReportResolvedPushEventListener listener; + + @BeforeEach + void setUp() { + listener = new ReportResolvedPushEventListener( + pushInstallationRepository, + pushDispatchService, + pushEventFlagService + ); + } + + @Test + void createsReporterDispatch() { + when(pushEventFlagService.isEnabled(PushEventType.REPORT)).thenReturn(true); + when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(7L, AppVariant.PRODUCTION)) + .thenReturn(true); + + listener.handle(new ReportResolvedEvent(3L, 7L, ReportStatus.REJECTED)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PushDispatchCommand.class); + verify(pushDispatchService).enqueue(captor.capture()); + PushDispatchCommand command = captor.getValue(); + assertThat(command.targetType()).isEqualTo(PushTargetType.USER); + assertThat(command.targetValue()).isEqualTo("7"); + assertThat(command.actionType()).isEqualTo(PushActionType.HOME); + assertThat(command.actionParams()).isEmpty(); + assertThat(command.title()).isEqualTo("신고 처리 결과를 확인해주세요."); + assertThat(command.body()).isEqualTo("접수한 신고의 처리가 완료되었습니다. 고대로에서 결과를 확인해주세요."); + assertThat(command.body()).doesNotContain("sensitive").doesNotContain("memo"); + assertThat(command.idempotencyKey()).isEqualTo("report-result:3:REJECTED:7:production"); + } + + @Test + void doesNotCreateDispatchWhenFeatureFlagIsFalse() { + when(pushEventFlagService.isEnabled(PushEventType.REPORT)).thenReturn(false); + + listener.handle(new ReportResolvedEvent(3L, 7L, ReportStatus.REJECTED)); + + verify(pushDispatchService, never()).enqueue(org.mockito.ArgumentMatchers.any()); + } + + @Test + void doesNotCreateDispatchWhenReporterIsUnknown() { + when(pushEventFlagService.isEnabled(PushEventType.REPORT)).thenReturn(true); + + listener.handle(new ReportResolvedEvent(3L, null, ReportStatus.REJECTED)); + + verify(pushDispatchService, never()).enqueue(org.mockito.ArgumentMatchers.any()); + } + + @Test + void doesNotCreateDispatchWhenReporterHasNoProductionInstallation() { + when(pushEventFlagService.isEnabled(PushEventType.REPORT)).thenReturn(true); + when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(7L, AppVariant.PRODUCTION)) + .thenReturn(false); + + listener.handle(new ReportResolvedEvent(3L, 7L, ReportStatus.REJECTED)); + + verify(pushDispatchService, never()).enqueue(org.mockito.ArgumentMatchers.any()); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/repository/PushMessageRepositoryQueryTest.java b/src/test/java/devkor/com/teamcback/domain/notification/repository/PushMessageRepositoryQueryTest.java new file mode 100644 index 00000000..6539def0 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/repository/PushMessageRepositoryQueryTest.java @@ -0,0 +1,42 @@ +package devkor.com.teamcback.domain.notification.repository; + +import java.lang.reflect.Method; +import org.junit.jupiter.api.Test; +import org.springframework.data.jpa.repository.Query; + +import static org.assertj.core.api.Assertions.assertThat; + +class PushMessageRepositoryQueryTest { + + @Test + void dueReceiptQueryTargetsOnlyReceiptPendingMessagesWithAvailableReceipts() throws Exception { + Method method = PushMessageRepository.class.getMethod( + "findDueReceiptPendingForUpdateSkipLocked", + java.time.LocalDateTime.class, + int.class + ); + + String query = method.getAnnotation(Query.class).value(); + + assertThat(query).contains("status = 'RECEIPT_PENDING'"); + assertThat(query).contains("expo_ticket_id IS NOT NULL"); + assertThat(query).contains("receipt_available_at <= :now"); + assertThat(query).contains("FOR UPDATE SKIP LOCKED"); + } + + @Test + void staleSendingQueryTargetsOnlyOldSendingMessages() throws Exception { + Method method = PushMessageRepository.class.getMethod( + "findStaleSendingForUpdateSkipLocked", + java.time.LocalDateTime.class, + int.class + ); + + String query = method.getAnnotation(Query.class).value(); + + assertThat(query).contains("status = 'SENDING'"); + assertThat(query).contains("updated_at <= :staleBefore"); + assertThat(query).contains("LIMIT :limit"); + assertThat(query).contains("FOR UPDATE SKIP LOCKED"); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/resolver/PushTargetResolverTest.java b/src/test/java/devkor/com/teamcback/domain/notification/resolver/PushTargetResolverTest.java new file mode 100644 index 00000000..3e48a5d6 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/resolver/PushTargetResolverTest.java @@ -0,0 +1,78 @@ +package devkor.com.teamcback.domain.notification.resolver; + +import devkor.com.teamcback.domain.notification.entity.PushInstallation; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import devkor.com.teamcback.global.response.ResultCode; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PushTargetResolverTest { + + @Mock + private PushInstallationRepository pushInstallationRepository; + + private PushTargetResolver resolver; + + @BeforeEach + void setUp() { + resolver = new PushTargetResolver(pushInstallationRepository); + } + + @Test + void allTargetResolvesDistinctActiveProductionInstallations() { + PushInstallation first = new PushInstallation(1L, "install-1", "ExponentPushToken[first]", AppVariant.PRODUCTION); + PushInstallation duplicate = new PushInstallation(2L, "install-1", "ExponentPushToken[duplicate]", AppVariant.PRODUCTION); + PushInstallation second = new PushInstallation(3L, "install-2", "ExponentPushToken[second]", AppVariant.PRODUCTION); + when(pushInstallationRepository.findAllByAppVariantAndActiveTrue(AppVariant.PRODUCTION)) + .thenReturn(List.of(first, duplicate, second)); + + List resolved = resolver.resolve(PushTargetType.ALL, "ALL", AppVariant.PRODUCTION); + + assertThat(resolved).containsExactly(first, second); + } + + @Test + void allTargetRejectsNonAllTargetValue() { + assertThatThrownBy(() -> resolver.resolve(PushTargetType.ALL, "1", AppVariant.PRODUCTION)) + .isInstanceOf(GlobalException.class) + .extracting("resultCode") + .isEqualTo(ResultCode.INVALID_INPUT); + } + + @Test + void previewAllTargetAllowsZeroActiveInstallations() { + when(pushInstallationRepository.findAllByAppVariantAndActiveTrue(AppVariant.PRODUCTION)) + .thenReturn(List.of()); + + List resolved = resolver.resolveForPreview( + PushTargetType.ALL, + "ALL", + AppVariant.PRODUCTION + ); + + assertThat(resolved).isEmpty(); + } + + @Test + void actualAllTargetStillRejectsZeroActiveInstallations() { + when(pushInstallationRepository.findAllByAppVariantAndActiveTrue(AppVariant.PRODUCTION)) + .thenReturn(List.of()); + + assertThatThrownBy(() -> resolver.resolve(PushTargetType.ALL, "ALL", AppVariant.PRODUCTION)) + .isInstanceOf(GlobalException.class) + .extracting("resultCode") + .isEqualTo(ResultCode.INVALID_INPUT); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/scheduler/PushMessageRecoverySchedulerTest.java b/src/test/java/devkor/com/teamcback/domain/notification/scheduler/PushMessageRecoverySchedulerTest.java new file mode 100644 index 00000000..ca7f3706 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/scheduler/PushMessageRecoverySchedulerTest.java @@ -0,0 +1,36 @@ +package devkor.com.teamcback.domain.notification.scheduler; + +import devkor.com.teamcback.domain.notification.service.PushMessageRecoveryWorker; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class PushMessageRecoverySchedulerTest { + + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withBean(PushMessageRecoveryWorker.class, () -> mock(PushMessageRecoveryWorker.class)) + .withUserConfiguration(TestConfig.class); + + @Test + void schedulerIsDisabledByDefault() { + contextRunner.run(context -> assertThat(context) + .doesNotHaveBean(PushMessageRecoveryScheduler.class)); + } + + @Test + void schedulerIsDisabledWhenPropertyIsFalse() { + contextRunner + .withPropertyValues("push.recovery-worker.enabled=false") + .run(context -> assertThat(context) + .doesNotHaveBean(PushMessageRecoveryScheduler.class)); + } + + @Configuration + @Import(PushMessageRecoveryScheduler.class) + static class TestConfig { + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/service/AdminNotificationServiceTest.java b/src/test/java/devkor/com/teamcback/domain/notification/service/AdminNotificationServiceTest.java new file mode 100644 index 00000000..22f47b77 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/service/AdminNotificationServiceTest.java @@ -0,0 +1,368 @@ +package devkor.com.teamcback.domain.notification.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import devkor.com.teamcback.domain.notification.dto.payload.PushPayload; +import devkor.com.teamcback.domain.notification.dto.request.AdminPushDispatchReq; +import devkor.com.teamcback.domain.notification.dto.request.PushDispatchCommand; +import devkor.com.teamcback.domain.notification.dto.response.AdminPushDispatchDetailRes; +import devkor.com.teamcback.domain.notification.dto.response.AdminPushDispatchPreviewRes; +import devkor.com.teamcback.domain.notification.dto.response.AdminPushInstallationRes; +import devkor.com.teamcback.domain.notification.dto.response.PushDispatchEnqueueRes; +import devkor.com.teamcback.domain.notification.entity.PushDispatch; +import devkor.com.teamcback.domain.notification.entity.PushInstallation; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.NotificationType; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushDispatchStatus; +import devkor.com.teamcback.domain.notification.entity.type.PushMessageStatus; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import devkor.com.teamcback.domain.notification.factory.PushPayloadFactory; +import devkor.com.teamcback.domain.notification.repository.PushDispatchRepository; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.domain.notification.repository.PushMessageRepository; +import devkor.com.teamcback.domain.notification.resolver.PushTargetResolver; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import devkor.com.teamcback.global.response.ResultCode; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminNotificationServiceTest { + + @Mock + private PushInstallationRepository pushInstallationRepository; + + @Mock + private PushDispatchRepository pushDispatchRepository; + + @Mock + private PushMessageRepository pushMessageRepository; + + @Mock + private PushTargetResolver pushTargetResolver; + + @Mock + private PushPayloadFactory pushPayloadFactory; + + @Mock + private PushDispatchService pushDispatchService; + + @Test + void searchInstallationsDoesNotExposeExpoPushToken() throws Exception { + PushInstallation installation = installation(AppVariant.DEV); + when(pushInstallationRepository.findAllByUserIdOrderByModifiedAtDescPushInstallationIdDesc(1L)) + .thenReturn(List.of(installation)); + + List response = service(false) + .searchInstallations(1L, null); + + String json = new ObjectMapper().findAndRegisterModules().writeValueAsString(response); + assertThat(json).contains("installationId"); + assertThat(json).doesNotContain("expoPushToken"); + assertThat(json).doesNotContain("ExponentPushToken"); + } + + @Test + void previewResolvesTargetAndCreatesPayloadWithoutSavingDispatchOrMessages() { + PushInstallation installation = installation(AppVariant.DEV); + PushPayload payload = payload(); + + when(pushTargetResolver.resolveForPreview(PushTargetType.INSTALLATION, "install-1", AppVariant.DEV)) + .thenReturn(List.of(installation)); + when(pushPayloadFactory.createForPreDispatchValidation( + "title", + "body", + PushMode.TEST, + AppVariant.DEV, + PushActionType.TEST, + Map.of() + )).thenReturn(payload); + + AdminPushDispatchPreviewRes response = service(false).preview(testRequest()); + + assertThat(response.recipientCount()).isEqualTo(1); + assertThat(response.payload()).isSameAs(payload); + verify(pushDispatchRepository, never()).save(any(PushDispatch.class)); + verify(pushMessageRepository, never()).saveAll(any()); + verify(pushDispatchService, never()).enqueue(any(PushDispatchCommand.class)); + } + + @Test + void testModeRejectsUserTarget() { + AdminPushDispatchReq request = new AdminPushDispatchReq( + PushMode.TEST, + AppVariant.DEV, + PushTargetType.USER, + "1", + "title", + "body", + PushActionType.TEST, + Map.of(), + null + ); + + assertThatThrownBy(() -> service(false).preview(request)) + .isInstanceOf(GlobalException.class) + .extracting("resultCode") + .isEqualTo(ResultCode.INVALID_INPUT); + } + + @Test + void productionActualIsRejectedWhenProductionEnabledIsFalse() { + assertThatThrownBy(() -> service(false).enqueue( + 1L, + "key-1", + productionRequest(true) + )) + .isInstanceOf(GlobalException.class) + .extracting("resultCode") + .isEqualTo(ResultCode.FORBIDDEN); + + verify(pushDispatchService, never()).enqueue(any(PushDispatchCommand.class)); + } + + @Test + void productionActualRequiresConfirmTrue() { + assertThatThrownBy(() -> service(true).enqueue( + 1L, + "key-1", + productionRequest(false) + )) + .isInstanceOf(GlobalException.class) + .extracting("resultCode") + .isEqualTo(ResultCode.INVALID_INPUT); + + verify(pushDispatchService, never()).enqueue(any(PushDispatchCommand.class)); + } + + @Test + void enqueueDelegatesToPushDispatchService() { + PushDispatch dispatch = dispatch(PushMode.TEST, AppVariant.DEV, PushTargetType.INSTALLATION); + PushDispatchEnqueueRes enqueueResponse = new PushDispatchEnqueueRes(dispatch); + when(pushDispatchService.enqueue(any(PushDispatchCommand.class))) + .thenReturn(enqueueResponse); + + PushDispatchEnqueueRes response = service(false).enqueue( + 7L, + "key-1", + testRequest() + ); + + assertThat(response).isSameAs(enqueueResponse); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PushDispatchCommand.class); + verify(pushDispatchService).enqueue(captor.capture()); + PushDispatchCommand command = captor.getValue(); + assertThat(command.notificationType()).isEqualTo(NotificationType.GENERAL); + assertThat(command.mode()).isEqualTo(PushMode.TEST); + assertThat(command.appVariant()).isEqualTo(AppVariant.DEV); + assertThat(command.targetType()).isEqualTo(PushTargetType.INSTALLATION); + assertThat(command.idempotencyKey()).isEqualTo("key-1"); + assertThat(command.createdBy()).isEqualTo(7L); + } + + @Test + void actualAllTargetsOnlyActiveInstallationsResolvedForTheVariant() { + PushDispatch dispatch = dispatch(PushMode.ACTUAL, AppVariant.DEV, PushTargetType.ALL); + PushDispatchEnqueueRes enqueueResponse = new PushDispatchEnqueueRes(dispatch); + when(pushTargetResolver.resolveForPreview(PushTargetType.ALL, "ALL", AppVariant.DEV)) + .thenReturn(List.of(installation(AppVariant.DEV))); + when(pushPayloadFactory.createForPreDispatchValidation( + "title", + "body", + PushMode.ACTUAL, + AppVariant.DEV, + PushActionType.HOME, + Map.of() + )).thenReturn(payload()); + when(pushDispatchService.enqueue(any(PushDispatchCommand.class))) + .thenReturn(enqueueResponse); + + AdminPushDispatchReq request = new AdminPushDispatchReq( + PushMode.ACTUAL, + AppVariant.DEV, + PushTargetType.ALL, + "ALL", + "title", + "body", + PushActionType.HOME, + Map.of(), + false + ); + + AdminPushDispatchPreviewRes preview = service(false).preview(request); + PushDispatchEnqueueRes response = service(false).enqueue(7L, "broadcast-dev-1", request); + + assertThat(preview.recipientCount()).isEqualTo(1); + assertThat(response).isSameAs(enqueueResponse); + ArgumentCaptor captor = ArgumentCaptor.forClass(PushDispatchCommand.class); + verify(pushDispatchService).enqueue(captor.capture()); + assertThat(captor.getValue().targetType()).isEqualTo(PushTargetType.ALL); + assertThat(captor.getValue().targetValue()).isEqualTo("ALL"); + assertThat(captor.getValue().appVariant()).isEqualTo(AppVariant.DEV); + } + + @Test + void getDispatchCountsAllMessageStatuses() { + PushDispatch dispatch = dispatch(PushMode.ACTUAL, AppVariant.DEV, PushTargetType.USER); + ReflectionTestUtils.setField(dispatch, "pushDispatchId", 10L); + dispatch.updateRecipientCount(5); + + when(pushDispatchRepository.findById(10L)).thenReturn(Optional.of(dispatch)); + when(pushMessageRepository.countStatusesByDispatchIds(List.of(10L))) + .thenReturn(List.of( + new StatusCount(10L, PushMessageStatus.QUEUED, 1L), + new StatusCount(10L, PushMessageStatus.SENDING, 1L), + new StatusCount(10L, PushMessageStatus.DELIVERED, 2L), + new StatusCount(10L, PushMessageStatus.FAILED, 1L) + )); + + AdminPushDispatchDetailRes response = service(false).getDispatch(10L); + + assertThat(response.recipientCount()).isEqualTo(5); + assertThat(response.messageStatusCounts()).containsEntry(PushMessageStatus.QUEUED, 1L); + assertThat(response.messageStatusCounts()).containsEntry(PushMessageStatus.SENDING, 1L); + assertThat(response.messageStatusCounts()).containsEntry(PushMessageStatus.TICKET_RECEIVED, 0L); + assertThat(response.messageStatusCounts()).containsEntry(PushMessageStatus.RECEIPT_PENDING, 0L); + assertThat(response.messageStatusCounts()).containsEntry(PushMessageStatus.DELIVERED, 2L); + assertThat(response.messageStatusCounts()).containsEntry(PushMessageStatus.FAILED, 1L); + } + + private AdminNotificationService service(boolean productionEnabled) { + AdminNotificationService service = new AdminNotificationService( + pushInstallationRepository, + pushDispatchRepository, + pushMessageRepository, + pushTargetResolver, + pushPayloadFactory, + pushDispatchService + ); + ReflectionTestUtils.setField(service, "productionEnabled", productionEnabled); + return service; + } + + private AdminPushDispatchReq testRequest() { + return new AdminPushDispatchReq( + PushMode.TEST, + AppVariant.DEV, + PushTargetType.INSTALLATION, + "install-1", + "title", + "body", + PushActionType.TEST, + Map.of(), + null + ); + } + + private AdminPushDispatchReq productionRequest(boolean confirm) { + return new AdminPushDispatchReq( + PushMode.ACTUAL, + AppVariant.PRODUCTION, + PushTargetType.INSTALLATION, + "install-1", + "title", + "body", + PushActionType.HOME, + Map.of(), + confirm + ); + } + + private PushInstallation installation(AppVariant appVariant) { + PushInstallation installation = new PushInstallation( + 1L, + "install-1", + "ExponentPushToken[token]", + appVariant + ); + ReflectionTestUtils.setField(installation, "pushInstallationId", 100L); + ReflectionTestUtils.setField(installation, "createdAt", LocalDateTime.parse("2026-08-04T10:00:00")); + ReflectionTestUtils.setField(installation, "modifiedAt", LocalDateTime.parse("2026-08-04T11:00:00")); + return installation; + } + + private PushPayload payload() { + return new PushPayload( + "title", + "body", + new PushPayload.PushPayloadData( + 1, + "00000000-0000-4000-8000-000000000000", + "dev", + new PushPayload.PushPayloadAction(PushActionType.TEST.name(), Map.of()) + ) + ); + } + + private PushDispatch dispatch( + PushMode mode, + AppVariant appVariant, + PushTargetType targetType + ) { + PushDispatch dispatch = new PushDispatch( + NotificationType.GENERAL, + mode, + appVariant, + targetType, + targetType == PushTargetType.USER ? "1" : "install-1", + "title", + "body", + mode == PushMode.TEST ? PushActionType.TEST : PushActionType.HOME, + "{}", + "key-1", + 1L, + LocalDateTime.parse("2026-08-04T12:00:00") + ); + ReflectionTestUtils.setField(dispatch, "pushDispatchId", 10L); + return dispatch; + } + + private static class StatusCount implements PushMessageRepository.PushDispatchMessageStatusCount { + + private final Long dispatchId; + private final PushMessageStatus status; + private final long count; + + private StatusCount( + Long dispatchId, + PushMessageStatus status, + long count + ) { + this.dispatchId = dispatchId; + this.status = status; + this.count = count; + } + + @Override + public Long getDispatchId() { + return dispatchId; + } + + @Override + public PushMessageStatus getStatus() { + return status; + } + + @Override + public long getCount() { + return count; + } + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/service/NotificationTestServiceTest.java b/src/test/java/devkor/com/teamcback/domain/notification/service/NotificationTestServiceTest.java new file mode 100644 index 00000000..e286a5a3 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/service/NotificationTestServiceTest.java @@ -0,0 +1,193 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.client.ExpoPushClient; +import devkor.com.teamcback.domain.notification.dto.request.NotificationTestReq; +import devkor.com.teamcback.domain.notification.dto.response.NotificationTestRes; +import devkor.com.teamcback.domain.notification.entity.PushDispatch; +import devkor.com.teamcback.domain.notification.entity.PushInstallation; +import devkor.com.teamcback.domain.notification.entity.PushMessage; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.NotificationType; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushDispatchStatus; +import devkor.com.teamcback.domain.notification.entity.type.PushMessageStatus; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import devkor.com.teamcback.domain.notification.factory.PushPayloadFactory; +import devkor.com.teamcback.domain.notification.repository.PushDispatchRepository; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.domain.notification.repository.PushMessageRepository; +import java.lang.reflect.Field; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class NotificationTestServiceTest { + + private static final Clock CLOCK = Clock.fixed( + Instant.parse("2026-08-04T00:00:00Z"), + ZoneId.of("Asia/Seoul") + ); + + @Mock + private PushInstallationRepository pushInstallationRepository; + + @Mock + private PushDispatchRepository pushDispatchRepository; + + @Mock + private PushMessageRepository pushMessageRepository; + + @Mock + private PushPayloadFactory pushPayloadFactory; + + @Test + void sendTestEnqueuesQueuedMessageWithoutExpoClientDependency() { + PushInstallation installation = installation(); + when(pushInstallationRepository.findByInstallationId("install-1")) + .thenReturn(Optional.of(installation)); + when(pushDispatchRepository.findByIdempotencyKey("7b347ad7-6138-4cb7-af7d-f5201703a596")) + .thenReturn(Optional.empty()); + when(pushPayloadFactory.serializeActionParams(any())) + .thenReturn("{}"); + when(pushDispatchRepository.saveAndFlush(any(PushDispatch.class))) + .thenAnswer(invocation -> { + PushDispatch dispatch = invocation.getArgument(0); + ReflectionTestUtils.setField(dispatch, "pushDispatchId", 10L); + return dispatch; + }); + when(pushMessageRepository.saveAndFlush(any(PushMessage.class))) + .thenAnswer(invocation -> { + PushMessage message = invocation.getArgument(0); + ReflectionTestUtils.setField(message, "pushMessageId", 20L); + return message; + }); + + NotificationTestService service = service(); + NotificationTestRes response = service.sendTest( + 1L, + "7b347ad7-6138-4cb7-af7d-f5201703a596", + new NotificationTestReq(1, "install-1") + ); + + assertThat(response.notificationId()).isEqualTo("20"); + assertThat(response.messageStatus()).isEqualTo(PushMessageStatus.QUEUED); + assertThat(response.ticketId()).isNull(); + assertThat(hasExpoPushClientField()).isFalse(); + } + + @Test + void sendTestReturnsExistingDispatchMessageForSameIdempotencyKey() { + PushInstallation installation = installation(); + PushDispatch dispatch = dispatch(); + PushMessage message = message(dispatch, installation); + ReflectionTestUtils.setField(message, "pushMessageId", 20L); + + when(pushInstallationRepository.findByInstallationId("install-1")) + .thenReturn(Optional.of(installation)); + when(pushDispatchRepository.findByIdempotencyKey("7b347ad7-6138-4cb7-af7d-f5201703a596")) + .thenReturn(Optional.of(dispatch)); + when(pushMessageRepository.findAllByDispatch(dispatch)) + .thenReturn(List.of(message)); + + NotificationTestRes response = service().sendTest( + 1L, + "7b347ad7-6138-4cb7-af7d-f5201703a596", + new NotificationTestReq(1, "install-1") + ); + + assertThat(response.notificationId()).isEqualTo("20"); + assertThat(response.messageStatus()).isEqualTo(PushMessageStatus.QUEUED); + verify(pushDispatchRepository, never()).saveAndFlush(any(PushDispatch.class)); + verify(pushMessageRepository, never()).saveAndFlush(any(PushMessage.class)); + } + + @Test + void receiptPendingDoesNotCompleteDispatch() { + PushDispatch dispatch = dispatch(); + dispatch.updateRecipientCount(1); + + dispatch.updateStatusFromMessageSummary( + 1, + 0, + 0, + 0, + java.time.LocalDateTime.now(CLOCK) + ); + + assertThat(dispatch.getStatus()).isEqualTo(PushDispatchStatus.PROCESSING); + assertThat(dispatch.getCompletedAt()).isNull(); + } + + private NotificationTestService service() { + return new NotificationTestService( + pushInstallationRepository, + pushDispatchRepository, + pushMessageRepository, + pushPayloadFactory, + CLOCK + ); + } + + private PushInstallation installation() { + PushInstallation installation = new PushInstallation( + 1L, + "install-1", + "ExponentPushToken[token]", + AppVariant.DEV + ); + ReflectionTestUtils.setField(installation, "pushInstallationId", 100L); + return installation; + } + + private PushDispatch dispatch() { + PushDispatch dispatch = new PushDispatch( + NotificationType.GENERAL, + PushMode.TEST, + AppVariant.DEV, + PushTargetType.INSTALLATION, + "install-1", + "title", + "body", + PushActionType.TEST, + "{}", + "7b347ad7-6138-4cb7-af7d-f5201703a596", + 1L, + java.time.LocalDateTime.now(CLOCK) + ); + ReflectionTestUtils.setField(dispatch, "pushDispatchId", 10L); + return dispatch; + } + + private PushMessage message( + PushDispatch dispatch, + PushInstallation installation + ) { + return new PushMessage( + dispatch, + installation, + java.time.LocalDateTime.now(CLOCK) + ); + } + + private boolean hasExpoPushClientField() { + return Arrays.stream(NotificationTestService.class.getDeclaredFields()) + .map(Field::getType) + .anyMatch(ExpoPushClient.class::equals); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/service/PushEventFlagServiceTest.java b/src/test/java/devkor/com/teamcback/domain/notification/service/PushEventFlagServiceTest.java new file mode 100644 index 00000000..68063437 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/service/PushEventFlagServiceTest.java @@ -0,0 +1,96 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.dto.response.AdminPushEventFlagRes; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.PushEventType; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PushEventFlagServiceTest { + + @Mock + private StringRedisTemplate redisTemplate; + + @Mock + private ValueOperations valueOperations; + + private PushEventFlagService service; + + @BeforeEach + void setUp() { + service = new PushEventFlagService(redisTemplate); + ReflectionTestUtils.setField(service, "crowdDefaultEnabled", false); + ReflectionTestUtils.setField(service, "reportDefaultEnabled", true); + ReflectionTestUtils.setField(service, "characterDefaultEnabled", false); + ReflectionTestUtils.setField(service, "surveyDefaultEnabled", false); + ReflectionTestUtils.setField(service, "targetAppVariants", "DEV,PREVIEW"); + + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + } + + @Test + void returnsConfiguredDevelopmentTargetVariants() { + service.isEnabled(PushEventType.CROWD); + + assertThat(service.getTargetAppVariants()) + .containsExactly(AppVariant.DEV, AppVariant.PREVIEW); + } + + @Test + void returnsYamlDefaultWhenRedisValueDoesNotExist() { + when(valueOperations.get(PushEventType.CROWD.redisKey())).thenReturn(null); + when(valueOperations.get(PushEventType.REPORT.redisKey())).thenReturn(null); + when(valueOperations.get(PushEventType.SURVEY.redisKey())).thenReturn(null); + + assertThat(service.isEnabled(PushEventType.CROWD)).isFalse(); + assertThat(service.isEnabled(PushEventType.REPORT)).isTrue(); + assertThat(service.isEnabled(PushEventType.SURVEY)).isFalse(); + } + + @Test + void redisTrueOrFalseOverridesYamlDefault() { + when(valueOperations.get(PushEventType.CROWD.redisKey())).thenReturn("true"); + when(valueOperations.get(PushEventType.REPORT.redisKey())).thenReturn("false"); + + assertThat(service.isEnabled(PushEventType.CROWD)).isTrue(); + assertThat(service.isEnabled(PushEventType.REPORT)).isFalse(); + } + + @Test + void returnsYamlDefaultWhenRedisReadFails() { + when(valueOperations.get(PushEventType.REPORT.redisKey())).thenThrow(new RuntimeException("redis down")); + + assertThat(service.isEnabled(PushEventType.REPORT)).isTrue(); + } + + @Test + void updatedValueIsReflectedImmediatelyInQueryResult() { + when(valueOperations.get(PushEventType.CHARACTER.redisKey())) + .thenReturn(null) + .thenReturn("true") + .thenReturn("true"); + + assertThat(service.isEnabled(PushEventType.CHARACTER)).isFalse(); + + AdminPushEventFlagRes updated = service.updateFlag(PushEventType.CHARACTER, true); + assertThat(updated.enabled()).isTrue(); + + List flags = service.getFlags(); + assertThat(flags) + .filteredOn(flag -> flag.eventType() == PushEventType.CHARACTER) + .singleElement() + .extracting(AdminPushEventFlagRes::enabled) + .isEqualTo(true); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/service/PushMessageClaimServiceTest.java b/src/test/java/devkor/com/teamcback/domain/notification/service/PushMessageClaimServiceTest.java new file mode 100644 index 00000000..4cf6eec4 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/service/PushMessageClaimServiceTest.java @@ -0,0 +1,200 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.config.PushWorkerProperties; +import devkor.com.teamcback.domain.notification.dto.expo.ExpoPushErrorDetails; +import devkor.com.teamcback.domain.notification.dto.expo.ExpoPushTicket; +import devkor.com.teamcback.domain.notification.dto.worker.PushSendItem; +import devkor.com.teamcback.domain.notification.entity.PushDispatch; +import devkor.com.teamcback.domain.notification.entity.PushInstallation; +import devkor.com.teamcback.domain.notification.entity.PushMessage; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.NotificationType; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushMessageStatus; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import devkor.com.teamcback.domain.notification.factory.PushPayloadFactory; +import devkor.com.teamcback.domain.notification.repository.PushDispatchRepository; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.domain.notification.repository.PushMessageRepository; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PushMessageClaimServiceTest { + + private static final Clock CLOCK = Clock.fixed( + Instant.parse("2026-08-04T00:00:00Z"), + ZoneId.of("Asia/Seoul") + ); + + @Mock + private PushMessageRepository pushMessageRepository; + + @Mock + private PushInstallationRepository pushInstallationRepository; + + @Mock + private PushDispatchRepository pushDispatchRepository; + + @Mock + private PushPayloadFactory pushPayloadFactory; + + @Test + void ticketOkStoresReceiptAvailableAtFifteenMinutesLater() { + PushInstallation installation = installation(); + PushDispatch dispatch = dispatch(); + PushMessage message = message(dispatch, installation); + + stubRecordTickets(message, dispatch, PushMessageStatus.RECEIPT_PENDING); + + service().recordTickets( + List.of(new PushSendItem(20L, 10L, null)), + List.of(new ExpoPushTicket("ok", "ticket-1", null, null)) + ); + + assertThat(message.getStatus()).isEqualTo(PushMessageStatus.RECEIPT_PENDING); + assertThat(message.getExpoTicketId()).isEqualTo("ticket-1"); + assertThat(message.getReceiptAvailableAt()) + .isEqualTo(LocalDateTime.now(CLOCK).plusMinutes(15)); + } + + @Test + void deviceNotRegisteredTicketFailsMessageAndDeactivatesInstallation() { + PushInstallation installation = installation(); + PushDispatch dispatch = dispatch(); + PushMessage message = message(dispatch, installation); + + stubRecordTickets(message, dispatch, PushMessageStatus.FAILED); + when(pushInstallationRepository.findById(100L)) + .thenReturn(Optional.of(installation)); + + service().recordTickets( + List.of(new PushSendItem(20L, 10L, null)), + List.of(new ExpoPushTicket( + "error", + null, + null, + new ExpoPushErrorDetails("DeviceNotRegistered") + )) + ); + + assertThat(message.getStatus()).isEqualTo(PushMessageStatus.FAILED); + assertThat(message.getReceiptAvailableAt()).isNull(); + assertThat(installation.isActive()).isFalse(); + } + + private void stubRecordTickets( + PushMessage message, + PushDispatch dispatch, + PushMessageStatus status + ) { + when(pushMessageRepository.findAllByPushMessageIdIn(any(Collection.class))) + .thenReturn(List.of(message)); + when(pushMessageRepository.countStatusesByDispatchIds(any(Collection.class))) + .thenReturn(List.of(new StatusCount(10L, status, 1L))); + when(pushDispatchRepository.findAllById(any(Iterable.class))) + .thenReturn(List.of(dispatch)); + } + + private PushMessageClaimService service() { + return new PushMessageClaimService( + pushMessageRepository, + pushInstallationRepository, + pushDispatchRepository, + pushPayloadFactory, + new PushWorkerProperties(true, 100, 3, 30_000L), + CLOCK + ); + } + + private PushInstallation installation() { + PushInstallation installation = new PushInstallation( + 1L, + "install-1", + "ExponentPushToken[token]", + AppVariant.DEV + ); + ReflectionTestUtils.setField(installation, "pushInstallationId", 100L); + return installation; + } + + private PushDispatch dispatch() { + PushDispatch dispatch = new PushDispatch( + NotificationType.GENERAL, + PushMode.TEST, + AppVariant.DEV, + PushTargetType.INSTALLATION, + "install-1", + "title", + "body", + PushActionType.TEST, + "{}", + "7b347ad7-6138-4cb7-af7d-f5201703a596", + 1L, + LocalDateTime.now(CLOCK) + ); + ReflectionTestUtils.setField(dispatch, "pushDispatchId", 10L); + dispatch.updateRecipientCount(1); + return dispatch; + } + + private PushMessage message( + PushDispatch dispatch, + PushInstallation installation + ) { + PushMessage message = new PushMessage( + dispatch, + installation, + LocalDateTime.now(CLOCK) + ); + ReflectionTestUtils.setField(message, "pushMessageId", 20L); + return message; + } + + private static class StatusCount implements PushMessageRepository.PushDispatchMessageStatusCount { + + private final Long dispatchId; + private final PushMessageStatus status; + private final long count; + + private StatusCount( + Long dispatchId, + PushMessageStatus status, + long count + ) { + this.dispatchId = dispatchId; + this.status = status; + this.count = count; + } + + @Override + public Long getDispatchId() { + return dispatchId; + } + + @Override + public PushMessageStatus getStatus() { + return status; + } + + @Override + public long getCount() { + return count; + } + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/service/PushMessageRecoveryServiceTest.java b/src/test/java/devkor/com/teamcback/domain/notification/service/PushMessageRecoveryServiceTest.java new file mode 100644 index 00000000..271233d3 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/service/PushMessageRecoveryServiceTest.java @@ -0,0 +1,229 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.config.PushRecoveryWorkerProperties; +import devkor.com.teamcback.domain.notification.entity.PushDispatch; +import devkor.com.teamcback.domain.notification.entity.PushInstallation; +import devkor.com.teamcback.domain.notification.entity.PushMessage; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.NotificationType; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushDispatchStatus; +import devkor.com.teamcback.domain.notification.entity.type.PushMessageStatus; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import devkor.com.teamcback.domain.notification.repository.PushDispatchRepository; +import devkor.com.teamcback.domain.notification.repository.PushMessageRepository; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Collection; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PushMessageRecoveryServiceTest { + + private static final Clock CLOCK = Clock.fixed( + Instant.parse("2026-08-04T00:00:00Z"), + ZoneId.of("Asia/Seoul") + ); + + @Mock + private PushMessageRepository pushMessageRepository; + + @Mock + private PushDispatchRepository pushDispatchRepository; + + @Test + void staleSendingWithoutTicketFailsWithWorkerInterrupted() { + PushInstallation installation = installation(); + PushDispatch dispatch = dispatch(); + PushMessage message = message(dispatch, installation); + message.markSending(LocalDateTime.now(CLOCK).minusMinutes(31)); + + stubStaleMessages(List.of(message)); + stubStatusCounts(dispatch, List.of(new StatusCount(10L, PushMessageStatus.FAILED, 1L))); + + int recoveredCount = service(30, 100).recoverStaleSendingMessages(); + + assertThat(recoveredCount).isEqualTo(1); + assertThat(message.getStatus()).isEqualTo(PushMessageStatus.FAILED); + assertThat(message.getTicketStatus()).isEqualTo("worker_interrupted"); + assertThat(message.getTicketError()).isEqualTo("worker_interrupted"); + assertThat(message.getReceiptAvailableAt()).isNull(); + assertThat(dispatch.getStatus()).isEqualTo(PushDispatchStatus.FAILED); + } + + @Test + void staleSendingWithTicketRecoversToReceiptPendingWithoutResend() { + PushInstallation installation = installation(); + PushDispatch dispatch = dispatch(); + PushMessage message = message(dispatch, installation); + message.recordTicket("ok", "ticket-1", null, LocalDateTime.now(CLOCK).minusMinutes(40)); + message.markReceiptChecking(LocalDateTime.now(CLOCK).minusMinutes(31)); + ReflectionTestUtils.setField(message, "receiptAvailableAt", null); + + stubStaleMessages(List.of(message)); + stubStatusCounts(dispatch, List.of(new StatusCount(10L, PushMessageStatus.RECEIPT_PENDING, 1L))); + + int recoveredCount = service(30, 100).recoverStaleSendingMessages(); + + assertThat(recoveredCount).isEqualTo(1); + assertThat(message.getStatus()).isEqualTo(PushMessageStatus.RECEIPT_PENDING); + assertThat(message.getExpoTicketId()).isEqualTo("ticket-1"); + assertThat(message.getReceiptAvailableAt()).isEqualTo(LocalDateTime.now(CLOCK)); + assertThat(dispatch.getStatus()).isEqualTo(PushDispatchStatus.PROCESSING); + } + + @Test + void nonStaleSendingIsNotChangedWhenQueryDoesNotReturnIt() { + PushMessage message = message(dispatch(), installation()); + message.markSending(LocalDateTime.now(CLOCK).minusMinutes(29)); + when(pushMessageRepository.findStaleSendingForUpdateSkipLocked(any(LocalDateTime.class), eq(100))) + .thenReturn(List.of()); + + int recoveredCount = service(30, 100).recoverStaleSendingMessages(); + + assertThat(recoveredCount).isZero(); + assertThat(message.getStatus()).isEqualTo(PushMessageStatus.SENDING); + } + + @Test + void nonSendingMessageIsNotChangedWhenQueryDoesNotReturnIt() { + PushMessage message = message(dispatch(), installation()); + when(pushMessageRepository.findStaleSendingForUpdateSkipLocked(any(LocalDateTime.class), eq(100))) + .thenReturn(List.of()); + + int recoveredCount = service(30, 100).recoverStaleSendingMessages(); + + assertThat(recoveredCount).isZero(); + assertThat(message.getStatus()).isEqualTo(PushMessageStatus.QUEUED); + } + + @Test + void recoveryUsesConfiguredStaleThresholdAndBatchSize() { + when(pushMessageRepository.findStaleSendingForUpdateSkipLocked(any(LocalDateTime.class), eq(7))) + .thenReturn(List.of()); + + service(45, 7).recoverStaleSendingMessages(); + + ArgumentCaptor staleBeforeCaptor = ArgumentCaptor.forClass(LocalDateTime.class); + verify(pushMessageRepository).findStaleSendingForUpdateSkipLocked(staleBeforeCaptor.capture(), eq(7)); + assertThat(staleBeforeCaptor.getValue()).isEqualTo(LocalDateTime.now(CLOCK).minusMinutes(45)); + } + + private PushMessageRecoveryService service( + long staleThresholdMinutes, + int batchSize + ) { + return new PushMessageRecoveryService( + pushMessageRepository, + pushDispatchRepository, + new PushRecoveryWorkerProperties(true, "0 0 4 * * *", staleThresholdMinutes, batchSize), + CLOCK + ); + } + + private void stubStaleMessages(List messages) { + when(pushMessageRepository.findStaleSendingForUpdateSkipLocked(any(LocalDateTime.class), eq(100))) + .thenReturn(messages); + } + + private void stubStatusCounts( + PushDispatch dispatch, + List counts + ) { + when(pushMessageRepository.countStatusesByDispatchIds(any(Collection.class))) + .thenReturn(counts); + when(pushDispatchRepository.findAllById(any(Iterable.class))) + .thenReturn(List.of(dispatch)); + } + + private PushInstallation installation() { + PushInstallation installation = new PushInstallation( + 1L, + "install-1", + "ExponentPushToken[token]", + AppVariant.DEV + ); + ReflectionTestUtils.setField(installation, "pushInstallationId", 100L); + return installation; + } + + private PushDispatch dispatch() { + PushDispatch dispatch = new PushDispatch( + NotificationType.GENERAL, + PushMode.TEST, + AppVariant.DEV, + PushTargetType.INSTALLATION, + "install-1", + "title", + "body", + PushActionType.TEST, + "{}", + "7b347ad7-6138-4cb7-af7d-f5201703a596", + 1L, + LocalDateTime.now(CLOCK) + ); + ReflectionTestUtils.setField(dispatch, "pushDispatchId", 10L); + dispatch.updateRecipientCount(1); + return dispatch; + } + + private PushMessage message( + PushDispatch dispatch, + PushInstallation installation + ) { + PushMessage message = new PushMessage( + dispatch, + installation, + LocalDateTime.now(CLOCK) + ); + ReflectionTestUtils.setField(message, "pushMessageId", 20L); + return message; + } + + private static class StatusCount implements PushMessageRepository.PushDispatchMessageStatusCount { + + private final Long dispatchId; + private final PushMessageStatus status; + private final long count; + + private StatusCount( + Long dispatchId, + PushMessageStatus status, + long count + ) { + this.dispatchId = dispatchId; + this.status = status; + this.count = count; + } + + @Override + public Long getDispatchId() { + return dispatchId; + } + + @Override + public PushMessageStatus getStatus() { + return status; + } + + @Override + public long getCount() { + return count; + } + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/service/PushReceiptClaimServiceTest.java b/src/test/java/devkor/com/teamcback/domain/notification/service/PushReceiptClaimServiceTest.java new file mode 100644 index 00000000..749e1c29 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/service/PushReceiptClaimServiceTest.java @@ -0,0 +1,305 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.config.PushReceiptWorkerProperties; +import devkor.com.teamcback.domain.notification.dto.expo.ExpoPushErrorDetails; +import devkor.com.teamcback.domain.notification.dto.expo.ExpoPushReceipt; +import devkor.com.teamcback.domain.notification.dto.worker.PushReceiptItem; +import devkor.com.teamcback.domain.notification.entity.PushDispatch; +import devkor.com.teamcback.domain.notification.entity.PushInstallation; +import devkor.com.teamcback.domain.notification.entity.PushMessage; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.NotificationType; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushDispatchStatus; +import devkor.com.teamcback.domain.notification.entity.type.PushMessageStatus; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import devkor.com.teamcback.domain.notification.repository.PushDispatchRepository; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.domain.notification.repository.PushMessageRepository; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class PushReceiptClaimServiceTest { + + private static final Clock CLOCK = Clock.fixed( + Instant.parse("2026-08-04T00:00:00Z"), + ZoneId.of("Asia/Seoul") + ); + + @Mock + private PushMessageRepository pushMessageRepository; + + @Mock + private PushInstallationRepository pushInstallationRepository; + + @Mock + private PushDispatchRepository pushDispatchRepository; + + @Test + void claimDueReceiptsMarksDueReceiptPendingMessagesAsSending() { + PushInstallation installation = installation(); + PushDispatch dispatch = dispatch(); + PushMessage message = receiptPendingMessage(dispatch, installation, "ticket-1"); + + when(pushMessageRepository.findDueReceiptPendingForUpdateSkipLocked(any(LocalDateTime.class), anyInt())) + .thenReturn(List.of(message)); + stubStatusCounts(dispatch, List.of(new StatusCount(10L, PushMessageStatus.SENDING, 1L))); + + List items = service().claimDueReceipts(); + + assertThat(items).containsExactly(new PushReceiptItem(20L, 10L, "ticket-1")); + assertThat(message.getStatus()).isEqualTo(PushMessageStatus.SENDING); + } + + @Test + void receiptOkMarksDeliveredByTicketIdMap() { + PushInstallation installation = installation(); + PushDispatch dispatch = dispatch(); + PushMessage first = receiptPendingMessage(dispatch, installation, "ticket-1"); + PushMessage second = receiptPendingMessage(dispatch, installation, "ticket-2"); + ReflectionTestUtils.setField(second, "pushMessageId", 21L); + + stubMessages(List.of(first, second)); + stubStatusCounts(dispatch, List.of(new StatusCount(10L, PushMessageStatus.DELIVERED, 2L))); + + service().recordReceipts( + List.of( + new PushReceiptItem(20L, 10L, "ticket-1"), + new PushReceiptItem(21L, 10L, "ticket-2") + ), + Map.of( + "ticket-2", new ExpoPushReceipt("ok", null, null), + "ticket-1", new ExpoPushReceipt("error", "failed", new ExpoPushErrorDetails("MessageTooBig")) + ) + ); + + assertThat(first.getStatus()).isEqualTo(PushMessageStatus.FAILED); + assertThat(first.getReceiptError()).isEqualTo("MessageTooBig"); + assertThat(second.getStatus()).isEqualTo(PushMessageStatus.DELIVERED); + assertThat(second.getReceiptError()).isNull(); + } + + @Test + void deviceNotRegisteredReceiptFailsMessageAndDeactivatesInstallation() { + PushInstallation installation = installation(); + PushDispatch dispatch = dispatch(); + PushMessage message = receiptPendingMessage(dispatch, installation, "ticket-1"); + + stubMessages(List.of(message)); + stubStatusCounts(dispatch, List.of(new StatusCount(10L, PushMessageStatus.FAILED, 1L))); + when(pushInstallationRepository.findById(100L)) + .thenReturn(Optional.of(installation)); + + service().recordReceipts( + List.of(new PushReceiptItem(20L, 10L, "ticket-1")), + Map.of("ticket-1", new ExpoPushReceipt( + "error", + null, + new ExpoPushErrorDetails("DeviceNotRegistered") + )) + ); + + assertThat(message.getStatus()).isEqualTo(PushMessageStatus.FAILED); + assertThat(installation.isActive()).isFalse(); + } + + @Test + void missingReceiptSchedulesLimitedRetry() { + PushInstallation installation = installation(); + PushDispatch dispatch = dispatch(); + PushMessage message = receiptPendingMessage(dispatch, installation, "ticket-1"); + + stubMessages(List.of(message)); + stubStatusCounts(dispatch, List.of(new StatusCount(10L, PushMessageStatus.RECEIPT_PENDING, 1L))); + + service().recordReceipts( + List.of(new PushReceiptItem(20L, 10L, "ticket-1")), + Map.of() + ); + + assertThat(message.getStatus()).isEqualTo(PushMessageStatus.RECEIPT_PENDING); + assertThat(message.getReceiptAttempts()).isEqualTo(1); + assertThat(message.getReceiptAvailableAt()).isEqualTo(LocalDateTime.now(CLOCK).plusMinutes(1)); + } + + @Test + void maxReceiptAttemptsMarksFailed() { + PushInstallation installation = installation(); + PushDispatch dispatch = dispatch(); + PushMessage message = receiptPendingMessage(dispatch, installation, "ticket-1"); + ReflectionTestUtils.setField(message, "receiptAttempts", 2); + + stubMessages(List.of(message)); + stubStatusCounts(dispatch, List.of(new StatusCount(10L, PushMessageStatus.FAILED, 1L))); + + service().recordReceipts( + List.of(new PushReceiptItem(20L, 10L, "ticket-1")), + Map.of() + ); + + assertThat(message.getStatus()).isEqualTo(PushMessageStatus.FAILED); + assertThat(message.getReceiptAvailableAt()).isNull(); + } + + @Test + void sentMoreThanTwentyFourHoursAgoExpiresWithoutReceiptRequestItem() { + PushInstallation installation = installation(); + PushDispatch dispatch = dispatch(); + PushMessage message = receiptPendingMessage(dispatch, installation, "ticket-1"); + ReflectionTestUtils.setField(message, "sentAt", LocalDateTime.now(CLOCK).minusHours(25)); + + when(pushMessageRepository.findDueReceiptPendingForUpdateSkipLocked(any(LocalDateTime.class), anyInt())) + .thenReturn(List.of(message)); + stubStatusCounts(dispatch, List.of(new StatusCount(10L, PushMessageStatus.FAILED, 1L))); + + List items = service().claimDueReceipts(); + + assertThat(items).isEmpty(); + assertThat(message.getStatus()).isEqualTo(PushMessageStatus.FAILED); + assertThat(message.getReceiptError()).isEqualTo("receipt_expired"); + } + + @Test + void dispatchSummaryUsesOnlyFinalStatusesForCompletion() { + PushDispatch allDelivered = dispatch(); + allDelivered.updateRecipientCount(2); + allDelivered.updateStatusFromMessageSummary(0, 0, 2, 0, LocalDateTime.now(CLOCK)); + assertThat(allDelivered.getStatus()).isEqualTo(PushDispatchStatus.COMPLETED); + + PushDispatch partial = dispatch(); + partial.updateRecipientCount(2); + partial.updateStatusFromMessageSummary(0, 0, 1, 1, LocalDateTime.now(CLOCK)); + assertThat(partial.getStatus()).isEqualTo(PushDispatchStatus.PARTIAL_FAILED); + + PushDispatch failed = dispatch(); + failed.updateRecipientCount(2); + failed.updateStatusFromMessageSummary(0, 0, 0, 2, LocalDateTime.now(CLOCK)); + assertThat(failed.getStatus()).isEqualTo(PushDispatchStatus.FAILED); + + PushDispatch processing = dispatch(); + processing.updateRecipientCount(2); + processing.updateStatusFromMessageSummary(1, 0, 1, 0, LocalDateTime.now(CLOCK)); + assertThat(processing.getStatus()).isEqualTo(PushDispatchStatus.PROCESSING); + } + + private PushReceiptClaimService service() { + return new PushReceiptClaimService( + pushMessageRepository, + pushInstallationRepository, + pushDispatchRepository, + new PushReceiptWorkerProperties(true, 1000, 3), + CLOCK + ); + } + + private void stubMessages(List messages) { + when(pushMessageRepository.findAllByPushMessageIdIn(any(Collection.class))) + .thenReturn(messages); + } + + private void stubStatusCounts( + PushDispatch dispatch, + List counts + ) { + when(pushMessageRepository.countStatusesByDispatchIds(any(Collection.class))) + .thenReturn(counts); + when(pushDispatchRepository.findAllById(any(Iterable.class))) + .thenReturn(List.of(dispatch)); + } + + private PushInstallation installation() { + PushInstallation installation = new PushInstallation( + 1L, + "install-1", + "ExponentPushToken[token]", + AppVariant.DEV + ); + ReflectionTestUtils.setField(installation, "pushInstallationId", 100L); + return installation; + } + + private PushDispatch dispatch() { + PushDispatch dispatch = new PushDispatch( + NotificationType.GENERAL, + PushMode.TEST, + AppVariant.DEV, + PushTargetType.INSTALLATION, + "install-1", + "title", + "body", + PushActionType.TEST, + "{}", + "7b347ad7-6138-4cb7-af7d-f5201703a596", + 1L, + LocalDateTime.now(CLOCK) + ); + ReflectionTestUtils.setField(dispatch, "pushDispatchId", 10L); + dispatch.updateRecipientCount(1); + return dispatch; + } + + private PushMessage receiptPendingMessage( + PushDispatch dispatch, + PushInstallation installation, + String ticketId + ) { + PushMessage message = new PushMessage( + dispatch, + installation, + LocalDateTime.now(CLOCK).minusMinutes(20) + ); + ReflectionTestUtils.setField(message, "pushMessageId", 20L); + message.recordTicket("ok", ticketId, null, LocalDateTime.now(CLOCK).minusMinutes(20)); + return message; + } + + private static class StatusCount implements PushMessageRepository.PushDispatchMessageStatusCount { + + private final Long dispatchId; + private final PushMessageStatus status; + private final long count; + + private StatusCount( + Long dispatchId, + PushMessageStatus status, + long count + ) { + this.dispatchId = dispatchId; + this.status = status; + this.count = count; + } + + @Override + public Long getDispatchId() { + return dispatchId; + } + + @Override + public PushMessageStatus getStatus() { + return status; + } + + @Override + public long getCount() { + return count; + } + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleServiceTest.java b/src/test/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleServiceTest.java new file mode 100644 index 00000000..494b3000 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleServiceTest.java @@ -0,0 +1,322 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.dto.request.AdminSurveyPushScheduleReq; +import devkor.com.teamcback.domain.notification.dto.response.SurveyReminderRes; +import devkor.com.teamcback.domain.notification.entity.SurveyPushSchedule; +import devkor.com.teamcback.domain.notification.entity.type.SurveyNotificationStage; +import devkor.com.teamcback.domain.notification.entity.type.SurveyPushScheduleStatus; +import devkor.com.teamcback.domain.notification.entity.type.SurveyReminderSuppressedBy; +import devkor.com.teamcback.domain.notification.repository.SurveyPushScheduleRepository; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import devkor.com.teamcback.global.response.ResultCode; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SurveyPushScheduleServiceTest { + + private static final String SURVEY_KEY = "fall-2026"; + private static final Clock FIXED_CLOCK = Clock.fixed( + Instant.parse("2026-08-06T01:00:00Z"), + ZoneId.of("Asia/Seoul") + ); + + @Mock + private SurveyPushScheduleRepository repository; + + private SurveyPushScheduleService service; + + @BeforeEach + void setUp() { + service = new SurveyPushScheduleService(repository, FIXED_CLOCK); + } + + @Test + void upsertAdminSchedulesCreatesThreeWholeAudienceSchedules() { + List saved = new ArrayList<>(); + when(repository.findByIdempotencyKey(any())).thenReturn(Optional.empty()); + when(repository.save(any(SurveyPushSchedule.class))).thenAnswer(invocation -> { + SurveyPushSchedule schedule = invocation.getArgument(0); + saved.add(schedule); + return schedule; + }); + when(repository.findAllBySurveyKeyAndNotificationStageIn(eq(SURVEY_KEY), anyCollection())) + .thenAnswer(ignored -> saved); + + service.upsertAdminSchedules( + SURVEY_KEY, + new AdminSurveyPushScheduleReq( + LocalDateTime.parse("2026-08-10T10:00:00"), + LocalDateTime.parse("2026-08-20T10:00:00"), + 100 + ) + ); + + assertThat(saved).hasSize(3); + assertThat(saved) + .extracting(SurveyPushSchedule::getNotificationStage) + .containsExactly( + SurveyNotificationStage.STARTED, + SurveyNotificationStage.D_MINUS_3, + SurveyNotificationStage.DEADLINE + ); + assertThat(saved.get(1).getScheduledAt()).isEqualTo(LocalDateTime.parse("2026-08-17T10:00:00")); + assertThat(saved).allSatisfy(schedule -> { + assertThat(schedule.getTargetUserId()).isNull(); + assertThat(schedule.getRewardPoint()).isEqualTo(100); + assertThat(schedule.getStatus()).isEqualTo(SurveyPushScheduleStatus.PENDING); + }); + } + + @Test + void upsertAdminSchedulesUpdatesOnlyPendingExistingSchedule() { + SurveyPushSchedule pending = schedule(SurveyNotificationStage.STARTED, null, "2026-08-10T10:00:00", 100); + SurveyPushSchedule completed = schedule(SurveyNotificationStage.D_MINUS_3, null, "2026-08-17T10:00:00", 100); + completed.complete(LocalDateTime.parse("2026-08-17T10:01:00")); + + when(repository.findByIdempotencyKey("survey:" + SURVEY_KEY + ":STARTED")) + .thenReturn(Optional.of(pending)); + when(repository.findByIdempotencyKey("survey:" + SURVEY_KEY + ":D_MINUS_3")) + .thenReturn(Optional.of(completed)); + when(repository.findByIdempotencyKey("survey:" + SURVEY_KEY + ":DEADLINE")) + .thenReturn(Optional.empty()); + when(repository.save(any(SurveyPushSchedule.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(repository.findAllBySurveyKeyAndNotificationStageIn(eq(SURVEY_KEY), anyCollection())) + .thenReturn(List.of(pending, completed)); + + service.upsertAdminSchedules( + SURVEY_KEY, + new AdminSurveyPushScheduleReq( + LocalDateTime.parse("2026-08-11T10:00:00"), + LocalDateTime.parse("2026-08-21T10:00:00"), + 200 + ) + ); + + assertThat(pending.getScheduledAt()).isEqualTo(LocalDateTime.parse("2026-08-11T10:00:00")); + assertThat(pending.getRewardPoint()).isEqualTo(200); + assertThat(completed.getScheduledAt()).isEqualTo(LocalDateTime.parse("2026-08-17T10:00:00")); + assertThat(completed.getRewardPoint()).isEqualTo(100); + assertThat(completed.getStatus()).isEqualTo(SurveyPushScheduleStatus.COMPLETED); + } + + @Test + void upsertAdminSchedulesValidatesTimesAndRewardPoint() { + assertThatThrownBy(() -> service.upsertAdminSchedules( + SURVEY_KEY, + new AdminSurveyPushScheduleReq( + LocalDateTime.parse("2026-08-18T10:00:00"), + LocalDateTime.parse("2026-08-20T10:00:00"), + 100 + ) + )) + .isInstanceOf(GlobalException.class) + .extracting("resultCode") + .isEqualTo(ResultCode.INVALID_INPUT); + + assertThatThrownBy(() -> service.upsertAdminSchedules( + SURVEY_KEY, + new AdminSurveyPushScheduleReq( + LocalDateTime.parse("2026-08-10T10:00:00"), + LocalDateTime.parse("2026-08-20T10:00:00"), + -1 + ) + )) + .isInstanceOf(GlobalException.class) + .extracting("resultCode") + .isEqualTo(ResultCode.INVALID_INPUT); + } + + @Test + void remindAfterLaterCreatesOrUpdatesOnePendingPersonalSchedule() { + SurveyPushSchedule deadline = schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-20T10:00:00", 100); + SurveyPushSchedule existing = schedule(SurveyNotificationStage.REMIND_AFTER_LATER, 7L, "2026-08-07T09:00:00", 50); + + when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) + .thenReturn(Optional.of(deadline)); + when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.D_MINUS_3)) + .thenReturn(Optional.empty()); + when(repository.findByIdempotencyKey("survey:" + SURVEY_KEY + ":REMIND_AFTER_LATER:7")) + .thenReturn(Optional.of(existing)); + + SurveyReminderRes response = service.remindAfterLater(SURVEY_KEY, 7L); + + assertThat(response.scheduled()).isTrue(); + assertThat(response.scheduledAt()).isEqualTo(LocalDateTime.parse("2026-08-07T10:00:00")); + assertThat(existing.getRewardPoint()).isEqualTo(100); + } + + @Test + void remindAfterLaterSuppressesByPriorityAndExpiry() { + assertReminderSuppressed( + "2026-08-07T10:00:00", + "2026-08-07T09:00:00", + SurveyReminderSuppressedBy.DEADLINE + ); + assertReminderSuppressed( + "2026-08-20T10:00:00", + "2026-08-07T09:00:00", + SurveyReminderSuppressedBy.D3 + ); + + when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) + .thenReturn(Optional.of(schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-07T09:59:59", 100))); + when(repository.findByIdempotencyKey("survey:" + SURVEY_KEY + ":REMIND_AFTER_LATER:7")) + .thenReturn(Optional.empty()); + + SurveyReminderRes response = service.remindAfterLater(SURVEY_KEY, 7L); + assertThat(response.scheduled()).isFalse(); + assertThat(response.scheduledAt()).isNull(); + assertThat(response.suppressedBy()).isEqualTo(SurveyReminderSuppressedBy.EXPIRED); + } + + @Test + void remindAfterLaterCancelsExistingPendingReminderWhenSuppressedByD3() { + SurveyPushSchedule existing = schedule(SurveyNotificationStage.REMIND_AFTER_LATER, 7L, "2026-08-07T09:00:00", 100); + + when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) + .thenReturn(Optional.of(schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-20T10:00:00", 100))); + when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.D_MINUS_3)) + .thenReturn(Optional.of(schedule(SurveyNotificationStage.D_MINUS_3, null, "2026-08-07T09:00:00", 100))); + when(repository.findByIdempotencyKey("survey:" + SURVEY_KEY + ":REMIND_AFTER_LATER:7")) + .thenReturn(Optional.of(existing)); + + SurveyReminderRes response = service.remindAfterLater(SURVEY_KEY, 7L); + + assertThat(response.scheduled()).isFalse(); + assertThat(response.scheduledAt()).isNull(); + assertThat(response.suppressedBy()).isEqualTo(SurveyReminderSuppressedBy.D3); + assertThat(existing.getStatus()).isEqualTo(SurveyPushScheduleStatus.CANCELLED); + assertThat(existing.getProcessedAt()).isEqualTo(LocalDateTime.parse("2026-08-06T10:00:00")); + verify(repository, never()).save(any(SurveyPushSchedule.class)); + } + + @Test + void remindAfterLaterCancelsExistingPendingReminderWhenSuppressedByDeadline() { + SurveyPushSchedule existing = schedule(SurveyNotificationStage.REMIND_AFTER_LATER, 7L, "2026-08-07T09:00:00", 100); + + when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) + .thenReturn(Optional.of(schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-07T11:00:00", 100))); + when(repository.findByIdempotencyKey("survey:" + SURVEY_KEY + ":REMIND_AFTER_LATER:7")) + .thenReturn(Optional.of(existing)); + + SurveyReminderRes response = service.remindAfterLater(SURVEY_KEY, 7L); + + assertThat(response.scheduled()).isFalse(); + assertThat(response.scheduledAt()).isNull(); + assertThat(response.suppressedBy()).isEqualTo(SurveyReminderSuppressedBy.DEADLINE); + assertThat(existing.getStatus()).isEqualTo(SurveyPushScheduleStatus.CANCELLED); + assertThat(existing.getProcessedAt()).isEqualTo(LocalDateTime.parse("2026-08-06T10:00:00")); + verify(repository, never()).save(any(SurveyPushSchedule.class)); + } + + @Test + void remindAfterLaterCancelsExistingPendingReminderWhenExpired() { + SurveyPushSchedule existing = schedule(SurveyNotificationStage.REMIND_AFTER_LATER, 7L, "2026-08-07T09:00:00", 100); + + when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) + .thenReturn(Optional.of(schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-07T09:59:59", 100))); + when(repository.findByIdempotencyKey("survey:" + SURVEY_KEY + ":REMIND_AFTER_LATER:7")) + .thenReturn(Optional.of(existing)); + + SurveyReminderRes response = service.remindAfterLater(SURVEY_KEY, 7L); + + assertThat(response.scheduled()).isFalse(); + assertThat(response.scheduledAt()).isNull(); + assertThat(response.suppressedBy()).isEqualTo(SurveyReminderSuppressedBy.EXPIRED); + assertThat(existing.getStatus()).isEqualTo(SurveyPushScheduleStatus.CANCELLED); + assertThat(existing.getProcessedAt()).isEqualTo(LocalDateTime.parse("2026-08-06T10:00:00")); + verify(repository, never()).save(any(SurveyPushSchedule.class)); + } + + @Test + void remindAfterLaterDoesNotCreateCancelledRowWhenSuppressedWithoutExistingReminder() { + when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) + .thenReturn(Optional.of(schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-20T10:00:00", 100))); + when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.D_MINUS_3)) + .thenReturn(Optional.of(schedule(SurveyNotificationStage.D_MINUS_3, null, "2026-08-07T09:00:00", 100))); + when(repository.findByIdempotencyKey("survey:" + SURVEY_KEY + ":REMIND_AFTER_LATER:7")) + .thenReturn(Optional.empty()); + + SurveyReminderRes response = service.remindAfterLater(SURVEY_KEY, 7L); + + assertThat(response.scheduled()).isFalse(); + assertThat(response.scheduledAt()).isNull(); + assertThat(response.suppressedBy()).isEqualTo(SurveyReminderSuppressedBy.D3); + verify(repository, never()).save(any(SurveyPushSchedule.class)); + } + + @Test + void remindAfterLaterDoesNotRecreateProcessedReminder() { + SurveyPushSchedule deadline = schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-20T10:00:00", 100); + SurveyPushSchedule completed = schedule(SurveyNotificationStage.REMIND_AFTER_LATER, 7L, "2026-08-07T09:00:00", 100); + completed.complete(LocalDateTime.parse("2026-08-07T09:01:00")); + + when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) + .thenReturn(Optional.of(deadline)); + when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.D_MINUS_3)) + .thenReturn(Optional.empty()); + when(repository.findByIdempotencyKey("survey:" + SURVEY_KEY + ":REMIND_AFTER_LATER:7")) + .thenReturn(Optional.of(completed)); + + SurveyReminderRes response = service.remindAfterLater(SURVEY_KEY, 7L); + + assertThat(response.scheduled()).isFalse(); + assertThat(response.suppressedBy()).isEqualTo(SurveyReminderSuppressedBy.ALREADY_PROCESSED); + } + + private void assertReminderSuppressed( + String deadlineAt, + String d3At, + SurveyReminderSuppressedBy suppressedBy + ) { + when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) + .thenReturn(Optional.of(schedule(SurveyNotificationStage.DEADLINE, null, deadlineAt, 100))); + when(repository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.D_MINUS_3)) + .thenReturn(Optional.of(schedule(SurveyNotificationStage.D_MINUS_3, null, d3At, 100))); + when(repository.findByIdempotencyKey("survey:" + SURVEY_KEY + ":REMIND_AFTER_LATER:7")) + .thenReturn(Optional.empty()); + + SurveyReminderRes response = service.remindAfterLater(SURVEY_KEY, 7L); + + assertThat(response.scheduled()).isFalse(); + assertThat(response.scheduledAt()).isNull(); + assertThat(response.suppressedBy()).isEqualTo(suppressedBy); + } + + private SurveyPushSchedule schedule( + SurveyNotificationStage stage, + Long targetUserId, + String scheduledAt, + int rewardPoint + ) { + return new SurveyPushSchedule( + SURVEY_KEY, + stage, + targetUserId, + LocalDateTime.parse(scheduledAt), + rewardPoint, + SurveyPushScheduleService.idempotencyKey(SURVEY_KEY, stage, targetUserId), + LocalDateTime.parse("2026-08-06T10:00:00") + ); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorkerTest.java b/src/test/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorkerTest.java new file mode 100644 index 00000000..b914c971 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/service/SurveyPushScheduleWorkerTest.java @@ -0,0 +1,276 @@ +package devkor.com.teamcback.domain.notification.service; + +import devkor.com.teamcback.domain.notification.dto.request.PushDispatchCommand; +import devkor.com.teamcback.domain.notification.entity.SurveyPushSchedule; +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.NotificationType; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushEventType; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.domain.notification.entity.type.PushTargetType; +import devkor.com.teamcback.domain.notification.entity.type.SurveyNotificationStage; +import devkor.com.teamcback.domain.notification.entity.type.SurveyPushScheduleStatus; +import devkor.com.teamcback.domain.notification.repository.PushInstallationRepository; +import devkor.com.teamcback.domain.notification.repository.SurveyPushScheduleRepository; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import devkor.com.teamcback.global.response.ResultCode; +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SurveyPushScheduleWorkerTest { + + private static final String SURVEY_KEY = "fall-2026"; + private static final Clock FIXED_CLOCK = Clock.fixed( + Instant.parse("2026-08-17T01:00:00Z"), + ZoneId.of("Asia/Seoul") + ); + + @Mock + private SurveyPushScheduleRepository surveyPushScheduleRepository; + + @Mock + private PushInstallationRepository pushInstallationRepository; + + @Mock + private PushDispatchService pushDispatchService; + + @Mock + private PushEventFlagService pushEventFlagService; + + private SurveyPushScheduleWorker worker; + + @BeforeEach + void setUp() { + worker = new SurveyPushScheduleWorker( + surveyPushScheduleRepository, + pushInstallationRepository, + pushDispatchService, + pushEventFlagService, + FIXED_CLOCK + ); + } + + @Test + void surveyFlagFalseDoesNotClaimOrEnqueue() { + when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(false); + + int processed = worker.processDueSchedulesOnce(); + + assertThat(processed).isZero(); + verify(surveyPushScheduleRepository, never()).findDuePendingForUpdateSkipLocked(any(), any(Integer.class)); + verify(pushDispatchService, never()).enqueue(any()); + } + + @Test + void dueStartedScheduleEnqueuesAllProductionActualGeneralAndCompletes() { + SurveyPushSchedule schedule = schedule(SurveyNotificationStage.STARTED, null, "2026-08-17T09:00:00", 100); + when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(true); + when(surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked(LocalDateTime.parse("2026-08-17T10:00:00"), 50)) + .thenReturn(List.of(schedule)); + when(pushInstallationRepository.existsByAppVariantAndActiveTrue(AppVariant.PRODUCTION)).thenReturn(true); + + worker.processDueSchedulesOnce(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PushDispatchCommand.class); + verify(pushDispatchService).enqueue(captor.capture()); + PushDispatchCommand command = captor.getValue(); + assertThat(command.notificationType()).isEqualTo(NotificationType.GENERAL); + assertThat(command.mode()).isEqualTo(PushMode.ACTUAL); + assertThat(command.appVariant()).isEqualTo(AppVariant.PRODUCTION); + assertThat(command.targetType()).isEqualTo(PushTargetType.ALL); + assertThat(command.targetValue()).isEqualTo("ALL"); + assertThat(command.actionType()).isEqualTo(PushActionType.HOME); + assertThat(command.actionParams()).isEmpty(); + assertThat(command.idempotencyKey()).isEqualTo("survey:" + SURVEY_KEY + ":STARTED:production"); + assertThat(schedule.getStatus()).isEqualTo(SurveyPushScheduleStatus.COMPLETED); + assertThat(schedule.getProcessedAt()).isEqualTo(LocalDateTime.parse("2026-08-17T10:00:00")); + } + + @Test + void dueReminderScheduleEnqueuesUserTarget() { + SurveyPushSchedule schedule = schedule(SurveyNotificationStage.REMIND_AFTER_LATER, 7L, "2026-08-17T09:00:00", 100); + when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(true); + when(surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked(LocalDateTime.parse("2026-08-17T10:00:00"), 50)) + .thenReturn(List.of(schedule)); + when(surveyPushScheduleRepository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) + .thenReturn(Optional.of(schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-20T10:00:00", 100))); + when(surveyPushScheduleRepository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.D_MINUS_3)) + .thenReturn(Optional.of(schedule(SurveyNotificationStage.D_MINUS_3, null, "2026-08-18T10:00:00", 100))); + when(pushInstallationRepository.existsByUserIdAndAppVariantAndActiveTrue(7L, AppVariant.PRODUCTION)).thenReturn(true); + + worker.processDueSchedulesOnce(); + + ArgumentCaptor captor = ArgumentCaptor.forClass(PushDispatchCommand.class); + verify(pushDispatchService).enqueue(captor.capture()); + assertThat(captor.getValue().targetType()).isEqualTo(PushTargetType.USER); + assertThat(captor.getValue().targetValue()).isEqualTo("7"); + assertThat(captor.getValue().title()).isEqualTo("잠깐, 설문을 잊지 않으셨나요?"); + assertThat(schedule.getStatus()).isEqualTo(SurveyPushScheduleStatus.COMPLETED); + } + + @Test + void noActiveTargetMarksSkippedWithProcessedAt() { + SurveyPushSchedule schedule = schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-17T09:00:00", 100); + when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(true); + when(surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked(LocalDateTime.parse("2026-08-17T10:00:00"), 50)) + .thenReturn(List.of(schedule)); + when(pushInstallationRepository.existsByAppVariantAndActiveTrue(AppVariant.PRODUCTION)).thenReturn(false); + + worker.processDueSchedulesOnce(); + + verify(pushDispatchService, never()).enqueue(any()); + assertThat(schedule.getStatus()).isEqualTo(SurveyPushScheduleStatus.SKIPPED); + assertThat(schedule.getProcessedAt()).isEqualTo(LocalDateTime.parse("2026-08-17T10:00:00")); + } + + @Test + void pushDispatchInvalidInputNotCausedByNoTargetKeepsPending() { + SurveyPushSchedule schedule = schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-17T09:00:00", 100); + when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(true); + when(surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked(LocalDateTime.parse("2026-08-17T10:00:00"), 50)) + .thenReturn(List.of(schedule)); + when(pushInstallationRepository.existsByAppVariantAndActiveTrue(AppVariant.PRODUCTION)).thenReturn(true); + when(pushDispatchService.enqueue(any())).thenThrow(new GlobalException(ResultCode.INVALID_INPUT)); + + worker.processDueSchedulesOnce(); + + verify(pushDispatchService).enqueue(any()); + assertThat(schedule.getStatus()).isEqualTo(SurveyPushScheduleStatus.PENDING); + assertThat(schedule.getProcessedAt()).isNull(); + } + + @Test + void pushDispatchGlobalExceptionKeepsPending() { + SurveyPushSchedule schedule = schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-17T09:00:00", 100); + when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(true); + when(surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked(LocalDateTime.parse("2026-08-17T10:00:00"), 50)) + .thenReturn(List.of(schedule)); + when(pushInstallationRepository.existsByAppVariantAndActiveTrue(AppVariant.PRODUCTION)).thenReturn(true); + when(pushDispatchService.enqueue(any())).thenThrow(new GlobalException(ResultCode.UNSUPPORTED_REQUEST)); + + worker.processDueSchedulesOnce(); + + verify(pushDispatchService).enqueue(any()); + assertThat(schedule.getStatus()).isEqualTo(SurveyPushScheduleStatus.PENDING); + assertThat(schedule.getProcessedAt()).isNull(); + } + + @Test + void pushDispatchRuntimeExceptionKeepsPending() { + SurveyPushSchedule schedule = schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-17T09:00:00", 100); + when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(true); + when(surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked(LocalDateTime.parse("2026-08-17T10:00:00"), 50)) + .thenReturn(List.of(schedule)); + when(pushInstallationRepository.existsByAppVariantAndActiveTrue(AppVariant.PRODUCTION)).thenReturn(true); + when(pushDispatchService.enqueue(any())).thenThrow(new RuntimeException("boom")); + + worker.processDueSchedulesOnce(); + + verify(pushDispatchService).enqueue(any()); + assertThat(schedule.getStatus()).isEqualTo(SurveyPushScheduleStatus.PENDING); + assertThat(schedule.getProcessedAt()).isNull(); + } + + @Test + void reminderIsCancelledWhenLatestD3DateHasPriority() { + SurveyPushSchedule schedule = schedule(SurveyNotificationStage.REMIND_AFTER_LATER, 7L, "2026-08-17T09:00:00", 100); + when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(true); + when(surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked(LocalDateTime.parse("2026-08-17T10:00:00"), 50)) + .thenReturn(List.of(schedule)); + when(surveyPushScheduleRepository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) + .thenReturn(Optional.of(schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-20T10:00:00", 100))); + when(surveyPushScheduleRepository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.D_MINUS_3)) + .thenReturn(Optional.of(schedule(SurveyNotificationStage.D_MINUS_3, null, "2026-08-17T10:00:00", 100))); + + worker.processDueSchedulesOnce(); + + verify(pushDispatchService, never()).enqueue(any()); + assertThat(schedule.getStatus()).isEqualTo(SurveyPushScheduleStatus.CANCELLED); + } + + @Test + void reminderIsCancelledWhenLatestDeadlineDateHasPriority() { + SurveyPushSchedule schedule = schedule(SurveyNotificationStage.REMIND_AFTER_LATER, 7L, "2026-08-17T09:00:00", 100); + when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(true); + when(surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked(LocalDateTime.parse("2026-08-17T10:00:00"), 50)) + .thenReturn(List.of(schedule)); + when(surveyPushScheduleRepository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) + .thenReturn(Optional.of(schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-17T10:00:00", 100))); + + worker.processDueSchedulesOnce(); + + verify(pushDispatchService, never()).enqueue(any()); + assertThat(schedule.getStatus()).isEqualTo(SurveyPushScheduleStatus.CANCELLED); + assertThat(schedule.getProcessedAt()).isEqualTo(LocalDateTime.parse("2026-08-17T10:00:00")); + } + + @Test + void reminderIsCancelledWhenNowIsAfterDeadline() { + SurveyPushSchedule schedule = schedule(SurveyNotificationStage.REMIND_AFTER_LATER, 7L, "2026-08-16T09:00:00", 100); + when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(true); + when(surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked(LocalDateTime.parse("2026-08-17T10:00:00"), 50)) + .thenReturn(List.of(schedule)); + when(surveyPushScheduleRepository.findBySurveyKeyAndNotificationStage(SURVEY_KEY, SurveyNotificationStage.DEADLINE)) + .thenReturn(Optional.of(schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-17T09:59:59", 100))); + + worker.processDueSchedulesOnce(); + + verify(pushDispatchService, never()).enqueue(any()); + assertThat(schedule.getStatus()).isEqualTo(SurveyPushScheduleStatus.CANCELLED); + assertThat(schedule.getProcessedAt()).isEqualTo(LocalDateTime.parse("2026-08-17T10:00:00")); + } + + @Test + void deadlineAndD3SchedulesDoNotRunReminderPriorityCancellation() { + SurveyPushSchedule started = schedule(SurveyNotificationStage.STARTED, null, "2026-08-17T09:00:00", 100); + SurveyPushSchedule d3 = schedule(SurveyNotificationStage.D_MINUS_3, null, "2026-08-17T09:00:00", 100); + SurveyPushSchedule deadline = schedule(SurveyNotificationStage.DEADLINE, null, "2026-08-17T09:00:00", 100); + when(pushEventFlagService.isEnabled(PushEventType.SURVEY)).thenReturn(true); + when(surveyPushScheduleRepository.findDuePendingForUpdateSkipLocked(LocalDateTime.parse("2026-08-17T10:00:00"), 50)) + .thenReturn(List.of(started, d3, deadline)); + when(pushInstallationRepository.existsByAppVariantAndActiveTrue(AppVariant.PRODUCTION)).thenReturn(true); + + worker.processDueSchedulesOnce(); + + verify(surveyPushScheduleRepository, never()).findBySurveyKeyAndNotificationStage(any(), any()); + verify(pushDispatchService, times(3)).enqueue(any()); + assertThat(started.getStatus()).isEqualTo(SurveyPushScheduleStatus.COMPLETED); + assertThat(d3.getStatus()).isEqualTo(SurveyPushScheduleStatus.COMPLETED); + assertThat(deadline.getStatus()).isEqualTo(SurveyPushScheduleStatus.COMPLETED); + } + + private SurveyPushSchedule schedule( + SurveyNotificationStage stage, + Long targetUserId, + String scheduledAt, + int rewardPoint + ) { + return new SurveyPushSchedule( + SURVEY_KEY, + stage, + targetUserId, + LocalDateTime.parse(scheduledAt), + rewardPoint, + SurveyPushScheduleService.idempotencyKey(SURVEY_KEY, stage, targetUserId), + LocalDateTime.parse("2026-08-06T10:00:00") + ); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactoryTest.java b/src/test/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactoryTest.java new file mode 100644 index 00000000..4e78095c --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/template/DomainPushContentFactoryTest.java @@ -0,0 +1,78 @@ +package devkor.com.teamcback.domain.notification.template; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class DomainPushContentFactoryTest { + + @Test + void placeBecameVacantJoinsBuildingAndPlaceWithSingleSpace() { + PushContent content = DomainPushContentFactory.placeBecameVacant( + " 신공학관 ", + " 라운지 " + ); + + assertThat(content.title()).isEqualTo("기다리던 자리가 생겼어요!"); + assertThat(content.body()).isEqualTo("신공학관 라운지이 한산해요. 방문하기 전 현황을 확인해보세요."); + } + + @Test + void placeBecameVacantDoesNotIncludeNullWhenOneValueExists() { + PushContent buildingOnly = DomainPushContentFactory.placeBecameVacant("신공학관", null); + PushContent placeOnly = DomainPushContentFactory.placeBecameVacant(null, "라운지"); + + assertThat(buildingOnly.body()).isEqualTo("신공학관이 한산해요. 방문하기 전 현황을 확인해보세요."); + assertThat(placeOnly.body()).isEqualTo("라운지이 한산해요. 방문하기 전 현황을 확인해보세요."); + assertThat(buildingOnly.body()).doesNotContain("null"); + assertThat(placeOnly.body()).doesNotContain("null"); + } + + @Test + void placeBecameVacantUsesFallbackWhenBothValuesAreBlank() { + PushContent content = DomainPushContentFactory.placeBecameVacant(" ", null); + + assertThat(content.body()).isEqualTo("즐겨찾기한 공간이 한산해요. 방문하기 전 현황을 확인해보세요."); + } + + @Test + void characterUnlockedUsesFallbackWhenNameIsNullOrBlank() { + PushContent nullName = DomainPushContentFactory.characterUnlocked(null); + PushContent blankName = DomainPushContentFactory.characterUnlocked(" "); + + assertThat(nullName.title()).isEqualTo("새 캐릭터가 기다리고 있어요!"); + assertThat(nullName.body()).isEqualTo("새로운 캐릭터을 만나러 가볼까요?"); + assertThat(blankName.body()).isEqualTo("새로운 캐릭터을 만나러 가볼까요?"); + } + + @Test + void reportResolvedCreatesConfiguredTitleAndBody() { + PushContent content = DomainPushContentFactory.reportResolved(); + + assertThat(content.title()).isEqualTo("신고 처리 결과를 확인해주세요."); + assertThat(content.body()).isEqualTo("접수한 신고의 처리가 완료되었습니다. 고대로에서 결과를 확인해주세요."); + } + @Test + void surveyContentsAreConfiguredExactly() { + assertThat(DomainPushContentFactory.surveyStarted()) + .isEqualTo(new PushContent( + "고대로를 함께 만들어주세요!", + "잠깐의 설문으로 고대로를 더 편리하게 만들어주세요." + )); + assertThat(DomainPushContentFactory.surveyDMinus3()) + .isEqualTo(new PushContent( + "단 3초! 고대로의 개선을 위해 도와주세요", + "잠깐의 설문으로 고대로를 더 편리하게 만들어주세요." + )); + assertThat(DomainPushContentFactory.surveyDeadline(100)) + .isEqualTo(new PushContent( + "설문이 오늘 마감돼요!", + "설문에 참여하면 100 포인트를 받을 수 있어요.(5초 소요)" + )); + assertThat(DomainPushContentFactory.surveyRemindAfterLater(100)) + .isEqualTo(new PushContent( + "잠깐, 설문을 잊지 않으셨나요?", + "지금 투표에 참여하고 100포인트를 받아보세요.(5초 소요)" + )); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/notification/validation/PushActionValidatorTest.java b/src/test/java/devkor/com/teamcback/domain/notification/validation/PushActionValidatorTest.java new file mode 100644 index 00000000..b58033b0 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/notification/validation/PushActionValidatorTest.java @@ -0,0 +1,38 @@ +package devkor.com.teamcback.domain.notification.validation; + +import devkor.com.teamcback.domain.notification.entity.type.AppVariant; +import devkor.com.teamcback.domain.notification.entity.type.PushActionType; +import devkor.com.teamcback.domain.notification.entity.type.PushMode; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import java.util.Map; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class PushActionValidatorTest { + + private final PushActionValidator validator = new PushActionValidator(); + + @Test + void characterStoreAllowsNoParams() { + Map params = validator.validateAndNormalize( + PushActionType.CHARACTER_STORE, + PushMode.ACTUAL, + AppVariant.PRODUCTION, + Map.of() + ); + + assertThat(params).isEmpty(); + } + + @Test + void characterStoreRejectsParams() { + assertThatThrownBy(() -> validator.validateAndNormalize( + PushActionType.CHARACTER_STORE, + PushMode.ACTUAL, + AppVariant.PRODUCTION, + Map.of("characterId", 1L) + )).isInstanceOf(GlobalException.class); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/report/service/ReportServiceTest.java b/src/test/java/devkor/com/teamcback/domain/report/service/ReportServiceTest.java new file mode 100644 index 00000000..32020469 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/report/service/ReportServiceTest.java @@ -0,0 +1,118 @@ +package devkor.com.teamcback.domain.report.service; + +import devkor.com.teamcback.domain.report.dto.request.UpdateReportStatusReq; +import devkor.com.teamcback.domain.report.entity.ReasonCategory; +import devkor.com.teamcback.domain.report.entity.Report; +import devkor.com.teamcback.domain.report.entity.ReportStatus; +import devkor.com.teamcback.domain.report.entity.TargetType; +import devkor.com.teamcback.domain.report.event.ReportResolvedEvent; +import devkor.com.teamcback.domain.report.repository.ReportRepository; +import devkor.com.teamcback.domain.review.repository.ReviewRepository; +import devkor.com.teamcback.domain.user.entity.Provider; +import devkor.com.teamcback.domain.user.entity.Role; +import devkor.com.teamcback.domain.user.entity.User; +import devkor.com.teamcback.domain.user.repository.UserRepository; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ReportServiceTest { + + @Mock + private ReportRepository reportRepository; + + @Mock + private ReviewRepository reviewRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private ApplicationEventPublisher eventPublisher; + + private ReportService reportService; + private User reporter; + + @BeforeEach + void setUp() { + reportService = new ReportService( + reportRepository, + reviewRepository, + userRepository, + eventPublisher + ); + + reporter = new User("reporter", "reporter@test.com", Role.USER, Provider.KAKAO); + ReflectionTestUtils.setField(reporter, "userId", 7L); + } + + @Test + void publishesEventWhenPendingReportBecomesFinalStatus() { + Report report = report(ReportStatus.PENDING, reporter); + when(reportRepository.findById(1L)).thenReturn(Optional.of(report)); + + reportService.updateReportStatus(1L, req(ReportStatus.REJECTED)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(ReportResolvedEvent.class); + verify(eventPublisher).publishEvent(captor.capture()); + assertThat(captor.getValue().reportId()).isEqualTo(1L); + assertThat(captor.getValue().reporterUserId()).isEqualTo(7L); + assertThat(captor.getValue().finalStatus()).isEqualTo(ReportStatus.REJECTED); + } + + @Test + void doesNotPublishWhenFinalReportIsReprocessed() { + Report report = report(ReportStatus.RESOLVED, reporter); + when(reportRepository.findById(1L)).thenReturn(Optional.of(report)); + + reportService.updateReportStatus(1L, req(ReportStatus.REJECTED)); + + verify(eventPublisher, never()).publishEvent(any()); + } + + @Test + void doesNotPublishWhenStatusDoesNotChangeToFinal() { + Report report = report(ReportStatus.PENDING, reporter); + when(reportRepository.findById(1L)).thenReturn(Optional.of(report)); + + reportService.updateReportStatus(1L, req(ReportStatus.PENDING)); + + verify(eventPublisher, never()).publishEvent(any()); + } + + private UpdateReportStatusReq req(ReportStatus status) { + UpdateReportStatusReq req = new UpdateReportStatusReq(); + req.setStatus(status); + return req; + } + + private Report report( + ReportStatus status, + User reporter + ) { + Report report = new Report( + TargetType.REVIEW, + 20L, + ReasonCategory.SPAM_OR_ADVERTISING, + "sensitive report content", + status, + reporter, + null + ); + ReflectionTestUtils.setField(report, "id", 1L); + return report; + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/usagesurvey/service/UsageSurveyServiceTest.java b/src/test/java/devkor/com/teamcback/domain/usagesurvey/service/UsageSurveyServiceTest.java new file mode 100644 index 00000000..9d53c9e7 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/usagesurvey/service/UsageSurveyServiceTest.java @@ -0,0 +1,142 @@ +package devkor.com.teamcback.domain.usagesurvey.service; + +import static devkor.com.teamcback.global.response.ResultCode.ALREADY_ANSWERED_USAGE_SURVEY; +import static devkor.com.teamcback.global.response.ResultCode.INVALID_USAGE_SURVEY_OPTION; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import devkor.com.teamcback.domain.usagesurvey.dto.request.RecordUsageSurveyDismissalReq; +import devkor.com.teamcback.domain.usagesurvey.dto.request.SubmitUsageSurveyResponseReq; +import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyDismissReason; +import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyDismissal; +import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyQuestion; +import devkor.com.teamcback.domain.usagesurvey.entity.UsageSurveyResponse; +import devkor.com.teamcback.domain.usagesurvey.repository.UsageSurveyDismissalRepository; +import devkor.com.teamcback.domain.usagesurvey.repository.UsageSurveyResponseRepository; +import devkor.com.teamcback.domain.user.entity.Provider; +import devkor.com.teamcback.domain.user.entity.Role; +import devkor.com.teamcback.domain.user.entity.User; +import devkor.com.teamcback.domain.user.repository.UserRepository; +import devkor.com.teamcback.global.annotation.UpdateScore; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class UsageSurveyServiceTest { + @Mock + private UsageSurveyResponseRepository responseRepository; + @Mock + private UsageSurveyDismissalRepository dismissalRepository; + @Mock + private UserRepository userRepository; + + private UsageSurveyService service; + private User user; + + @BeforeEach + void setUp() { + service = new UsageSurveyService( + responseRepository, + dismissalRepository, + userRepository + ); + user = new User("survey-user", "survey@test.com", Role.USER, Provider.KAKAO); + lenient().when(userRepository.findById(7L)).thenReturn(Optional.of(user)); + } + + @Test + void statusReturnsAnsweredQuestionsAndReward() { + when(responseRepository.findAllByUserUserId(7L)).thenReturn(List.of( + new UsageSurveyResponse( + user, + UsageSurveyQuestion.INSTALL_REASON, + "SCHOOL_INFO" + ) + )); + + var result = service.getStatus(7L); + + assertThat(result.answeredQuestionKeys()) + .containsExactly(UsageSurveyQuestion.INSTALL_REASON); + assertThat(result.rewardPoint()).isEqualTo(10); + } + + @Test + void validResponseIsStoredAndMethodCarriesTenPointReward() throws Exception { + var req = new SubmitUsageSurveyResponseReq( + UsageSurveyQuestion.DESIRED_FEATURE, + "STUDY_ROOM_LINK" + ); + + var result = service.submitResponse(7L, req); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(UsageSurveyResponse.class); + verify(responseRepository).save(captor.capture()); + assertThat(captor.getValue().getQuestionKey()) + .isEqualTo(UsageSurveyQuestion.DESIRED_FEATURE); + assertThat(captor.getValue().getOptionKey()).isEqualTo("STUDY_ROOM_LINK"); + assertThat(result.getRewardPoint()).isEqualTo(10); + assertThat(UsageSurveyService.class + .getMethod("submitResponse", Long.class, SubmitUsageSurveyResponseReq.class) + .getAnnotation(UpdateScore.class) + .addScore()).isEqualTo(10); + } + + @Test + void duplicateResponseIsRejectedBeforeReward() { + when(responseRepository.existsByUserUserIdAndQuestionKey( + 7L, + UsageSurveyQuestion.INSTALL_REASON + )).thenReturn(true); + + assertThatThrownBy(() -> service.submitResponse( + 7L, + new SubmitUsageSurveyResponseReq( + UsageSurveyQuestion.INSTALL_REASON, + "FACILITIES" + ) + )).isInstanceOf(GlobalException.class) + .extracting("resultCode") + .isEqualTo(ALREADY_ANSWERED_USAGE_SURVEY); + } + + @Test + void mismatchedOptionIsRejected() { + assertThatThrownBy(() -> service.submitResponse( + 7L, + new SubmitUsageSurveyResponseReq( + UsageSurveyQuestion.INSTALL_REASON, + "STUDY_ROOM_LINK" + ) + )).isInstanceOf(GlobalException.class) + .extracting("resultCode") + .isEqualTo(INVALID_USAGE_SURVEY_OPTION); + } + + @Test + void laterDismissalIsStoredForExitMetrics() { + service.recordDismissal(7L, new RecordUsageSurveyDismissalReq( + UsageSurveyQuestion.DISAPPOINTMENT, + UsageSurveyDismissReason.LATER + )); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(UsageSurveyDismissal.class); + verify(dismissalRepository).save(captor.capture()); + assertThat(captor.getValue().getQuestionKey()) + .isEqualTo(UsageSurveyQuestion.DISAPPOINTMENT); + assertThat(captor.getValue().getDismissReason()) + .isEqualTo(UsageSurveyDismissReason.LATER); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/user/entity/LevelTest.java b/src/test/java/devkor/com/teamcback/domain/user/entity/LevelTest.java new file mode 100644 index 00000000..e164bfa9 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/user/entity/LevelTest.java @@ -0,0 +1,63 @@ +package devkor.com.teamcback.domain.user.entity; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +class LevelTest { + + @DisplayName("점수 구간별 레벨 계산 (0/5/20/40/60)") + @ParameterizedTest + @CsvSource({ + "0, LEVEL1", "4, LEVEL1", + "5, LEVEL2", "19, LEVEL2", + "20, LEVEL3", "39, LEVEL3", + "40, LEVEL4", "59, LEVEL4", + "60, LEVEL5", "999, LEVEL5", + }) + void fromScore(long score, Level expected) { + assertEquals(expected, Level.fromScore(score)); + } + + @DisplayName("다음 레벨 체인") + @Test + void getNextLevel() { + assertEquals(Level.LEVEL2, Level.LEVEL1.getNextLevel()); + assertEquals(Level.LEVEL3, Level.LEVEL2.getNextLevel()); + assertEquals(Level.LEVEL4, Level.LEVEL3.getNextLevel()); + assertEquals(Level.LEVEL5, Level.LEVEL4.getNextLevel()); + assertNull(Level.LEVEL5.getNextLevel()); + } + + @DisplayName("updateScore 시 score와 level이 함께 갱신") + @Test + void updateScoreSyncsLevel() { + User user = new User("tester", "tester@test.com", Role.USER, Provider.KAKAO); + assertEquals(Level.LEVEL1, user.getLevel()); + + user.updateScore(10L, true); + assertEquals(Level.LEVEL2, user.getLevel()); + assertEquals(10L, user.getScore()); + + // 리뷰 삭제 등으로 점수가 내려가면 레벨도 함께 하락 + user.updateScore(3L, true); + assertEquals(Level.LEVEL1, user.getLevel()); + } + + @DisplayName("포인트 적립/차감은 0 미만으로 내려가지 않음") + @Test + void addPointFloorsAtZero() { + User user = new User("tester", "tester@test.com", Role.USER, Provider.KAKAO); + assertEquals(0L, user.getPoint()); + + user.addPoint(10); + assertEquals(10L, user.getPoint()); + + user.addPoint(-15); // 리뷰 삭제 등으로 적립분보다 큰 차감이 와도 0에서 멈춤 + assertEquals(0L, user.getPoint()); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/user/repository/UserRepositoryBackfillTest.java b/src/test/java/devkor/com/teamcback/domain/user/repository/UserRepositoryBackfillTest.java new file mode 100644 index 00000000..f2a5feb7 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/user/repository/UserRepositoryBackfillTest.java @@ -0,0 +1,91 @@ +package devkor.com.teamcback.domain.user.repository; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import devkor.com.teamcback.domain.user.entity.Level; +import devkor.com.teamcback.domain.user.entity.Provider; +import devkor.com.teamcback.domain.user.entity.Role; +import devkor.com.teamcback.domain.user.entity.User; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import devkor.com.teamcback.global.config.QueryDslConfig; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; + +@DataJpaTest +@ActiveProfiles("test") +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +@Import(QueryDslConfig.class) // @Repository인 QueryDSL 커스텀 구현체 스캔에 필요 +class UserRepositoryBackfillTest { + @Autowired + UserRepository userRepository; + + @Autowired + EntityManager em; + + @DisplayName("score와 불일치하는 level만 백필하고, 재실행 시 0건 (멱등)") + @Test + void backfillLevels() { + User staleUser = userRepository.save(new User("stale", "stale@test.com", Role.USER, Provider.KAKAO)); + staleUser.updateScore(60L, false); // level = LEVEL5 + User freshUser = userRepository.save(new User("fresh", "fresh@test.com", Role.USER, Provider.KAKAO)); + freshUser.updateScore(10L, false); // level = LEVEL2 + em.flush(); + + // 컬럼 도입 이전의 레거시 상태(레벨 불일치)를 native SQL로 재현 + em.createNativeQuery("UPDATE tb_user SET level = 'LEVEL1' WHERE username = 'stale'").executeUpdate(); + em.clear(); + + int updated = userRepository.backfillLevels(); + em.clear(); + + assertEquals(1, updated); + assertEquals(Level.LEVEL5, userRepository.findByUserId(staleUser.getUserId()).getLevel()); + assertEquals(Level.LEVEL2, userRepository.findByUserId(freshUser.getUserId()).getLevel()); + + // 두 번째 실행은 아무것도 갱신하지 않음 + assertEquals(0, userRepository.backfillLevels()); + } + + @DisplayName("point가 NULL인 레거시 행만 score 값으로 백필 (사용 후 0이 된 잔액은 재적립 안 됨)") + @Test + void backfillPoints() { + User legacyUser = userRepository.save(new User("legacy", "legacy@test.com", Role.USER, Provider.KAKAO)); + legacyUser.updateScore(30L, false); + User spentUser = userRepository.save(new User("spent", "spent@test.com", Role.USER, Provider.KAKAO)); + spentUser.updateScore(30L, false); // point는 자바 초기값 0 (이미 초기화된 사용자로 간주) + em.flush(); + + // 컬럼 도입 이전의 레거시 상태(point NULL)를 native SQL로 재현 + em.createNativeQuery("UPDATE tb_user SET point = NULL WHERE username = 'legacy'").executeUpdate(); + em.clear(); + + assertEquals(1, userRepository.backfillPoints()); + em.clear(); + + assertEquals(30L, userRepository.findByUserId(legacyUser.getUserId()).getPoint()); + assertEquals(0L, userRepository.findByUserId(spentUser.getUserId()).getPoint()); // NULL 아니면 미변경 + + // 두 번째 실행은 아무것도 갱신하지 않음 (멱등) + assertEquals(0, userRepository.backfillPoints()); + } + + @DisplayName("포인트 차감: 잔액이 충분할 때만 원자적으로 차감") + @Test + void deductPoint() { + User user = userRepository.save(new User("buyer", "buyer@test.com", Role.USER, Provider.KAKAO)); + user.addPoint(10); + em.flush(); + + assertEquals(1, userRepository.deductPoint(user.getUserId(), 7)); + assertEquals(3L, userRepository.findByUserId(user.getUserId()).getPoint()); + + // 잔액(3)보다 큰 금액은 차감 실패 + assertEquals(0, userRepository.deductPoint(user.getUserId(), 4)); + assertEquals(3L, userRepository.findByUserId(user.getUserId()).getPoint()); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceAdminLoginTest.java b/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceAdminLoginTest.java new file mode 100644 index 00000000..f61f5538 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceAdminLoginTest.java @@ -0,0 +1,118 @@ +package devkor.com.teamcback.domain.user.service; + +import static devkor.com.teamcback.global.response.ResultCode.FORBIDDEN; +import static devkor.com.teamcback.global.response.ResultCode.INVALID_INPUT; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import devkor.com.teamcback.domain.bookmark.repository.BookmarkRepository; +import devkor.com.teamcback.domain.bookmark.repository.CategoryRepository; +import devkor.com.teamcback.domain.bookmark.repository.UserBookmarkLogRepository; +import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; +import devkor.com.teamcback.domain.notification.service.PushInstallationService; +import devkor.com.teamcback.domain.suggestion.repository.SuggestionRepository; +import devkor.com.teamcback.domain.user.dto.request.AdminLoginReq; +import devkor.com.teamcback.domain.user.dto.response.AdminLoginRes; +import devkor.com.teamcback.domain.user.entity.Provider; +import devkor.com.teamcback.domain.user.entity.Role; +import devkor.com.teamcback.domain.user.entity.User; +import devkor.com.teamcback.domain.user.repository.UserRepository; +import devkor.com.teamcback.domain.user.validator.AppleValidator; +import devkor.com.teamcback.domain.user.validator.GoogleValidator; +import devkor.com.teamcback.domain.user.validator.KakaoValidator; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import devkor.com.teamcback.global.jwt.JwtUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class UserServiceAdminLoginTest { + @InjectMocks + UserService userService; + + @Mock UserRepository userRepository; + @Mock CategoryRepository categoryRepository; + @Mock BookmarkRepository bookmarkRepository; + @Mock UserBookmarkLogRepository userBookmarkLogRepository; + @Mock SuggestionRepository suggestionRepository; + @Mock UserCharacterRepository userCharacterRepository; + @Mock JwtUtil jwtUtil; + @Mock KakaoValidator kakaoValidator; + @Mock GoogleValidator googleValidator; + @Mock AppleValidator appleValidator; + @Mock PasswordEncoder passwordEncoder; + @Mock PushInstallationService pushInstallationService; + + AdminLoginReq request; + + @BeforeEach + void setUp() { + request = new AdminLoginReq(); + ReflectionTestUtils.setField(request, "provider", Provider.GOOGLE); + ReflectionTestUtils.setField(request, "token", "google-id-token"); + } + + @Test + void issuesTokensOnlyForExistingAdmin() { + User admin = new User("operator", "admin@example.com", Role.ADMIN, Provider.GOOGLE); + ReflectionTestUtils.setField(admin, "userId", 7L); + when(googleValidator.validateToken("google-id-token")).thenReturn("admin@example.com"); + when(userRepository.findByEmailAndProvider("admin@example.com", Provider.GOOGLE)).thenReturn(admin); + when(jwtUtil.createAccessToken("7", "ROLE_ADMIN")).thenReturn("access"); + when(jwtUtil.createRefreshToken("7", "ROLE_ADMIN")).thenReturn("refresh"); + + AdminLoginRes result = userService.adminLogin(request); + + assertEquals("access", result.getAccessToken()); + assertEquals("refresh", result.getRefreshToken()); + assertEquals(7L, result.getUserId()); + assertEquals("admin@example.com", result.getEmail()); + } + + @Test + void usesDedicatedKakaoAdminAudience() { + ReflectionTestUtils.setField(request, "provider", Provider.KAKAO); + ReflectionTestUtils.setField(request, "token", "kakao-admin-id-token"); + User admin = new User("operator", "admin@example.com", Role.ADMIN, Provider.KAKAO); + ReflectionTestUtils.setField(admin, "userId", 8L); + when(kakaoValidator.validateAdminToken("kakao-admin-id-token")).thenReturn("admin@example.com"); + when(userRepository.findByEmailAndProvider("admin@example.com", Provider.KAKAO)).thenReturn(admin); + + userService.adminLogin(request); + + verify(kakaoValidator).validateAdminToken("kakao-admin-id-token"); + verify(kakaoValidator, never()).validateToken("kakao-admin-id-token"); + } + + @Test + void rejectsNonAdminWithoutCreatingUser() { + User user = new User("user", "user@example.com", Role.USER, Provider.GOOGLE); + when(googleValidator.validateToken("google-id-token")).thenReturn("user@example.com"); + when(userRepository.findByEmailAndProvider("user@example.com", Provider.GOOGLE)).thenReturn(user); + + GlobalException exception = assertThrows(GlobalException.class, () -> userService.adminLogin(request)); + + assertEquals(FORBIDDEN, exception.getResultCode()); + verify(userRepository, never()).save(any(User.class)); + } + + @Test + void rejectsUnsupportedProviderBeforeTokenValidation() { + ReflectionTestUtils.setField(request, "provider", Provider.APPLE); + + GlobalException exception = assertThrows(GlobalException.class, () -> userService.adminLogin(request)); + + assertEquals(INVALID_INPUT, exception.getResultCode()); + verify(appleValidator, never()).validateToken("google-id-token"); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceDeleteUserTest.java b/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceDeleteUserTest.java new file mode 100644 index 00000000..2062522b --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceDeleteUserTest.java @@ -0,0 +1,80 @@ +package devkor.com.teamcback.domain.user.service; + +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import devkor.com.teamcback.domain.bookmark.repository.BookmarkRepository; +import devkor.com.teamcback.domain.bookmark.repository.CategoryRepository; +import devkor.com.teamcback.domain.bookmark.repository.UserBookmarkLogRepository; +import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; +import devkor.com.teamcback.domain.notification.service.PushInstallationService; +import devkor.com.teamcback.domain.suggestion.repository.SuggestionRepository; +import devkor.com.teamcback.domain.user.entity.Provider; +import devkor.com.teamcback.domain.user.entity.Role; +import devkor.com.teamcback.domain.user.entity.User; +import devkor.com.teamcback.domain.user.repository.UserRepository; +import devkor.com.teamcback.domain.user.validator.AppleValidator; +import devkor.com.teamcback.domain.user.validator.GoogleValidator; +import devkor.com.teamcback.domain.user.validator.KakaoValidator; +import devkor.com.teamcback.global.jwt.JwtUtil; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class UserServiceDeleteUserTest { + @InjectMocks + UserService userService; + + @Mock + UserRepository userRepository; + @Mock + CategoryRepository categoryRepository; + @Mock + BookmarkRepository bookmarkRepository; + @Mock + UserBookmarkLogRepository userBookmarkLogRepository; + @Mock + SuggestionRepository suggestionRepository; + @Mock + UserCharacterRepository userCharacterRepository; + @Mock + PushInstallationService pushInstallationService; + @Mock + JwtUtil jwtUtil; + @Mock + KakaoValidator kakaoValidator; + @Mock + GoogleValidator googleValidator; + @Mock + AppleValidator appleValidator; + @Mock + PasswordEncoder passwordEncoder; + + @DisplayName("회원 탈퇴 시 캐릭터 보유 이력을 사용자 삭제 전에 정리 (FK 제약)") + @Test + void deleteUserCleansUpOwnedCharacters() { + User user = new User("tester", "tester@test.com", Role.USER, Provider.KAKAO); + ReflectionTestUtils.setField(user, "userId", 1L); + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(categoryRepository.findByUser(user)).thenReturn(List.of()); + when(suggestionRepository.findByUser(user)).thenReturn(List.of()); + when(userBookmarkLogRepository.findByUser(user)).thenReturn(List.of()); + + userService.deleteUser(1L); + + InOrder inOrder = Mockito.inOrder(userCharacterRepository, userRepository); + inOrder.verify(userCharacterRepository).deleteAllByUser(user); // 소유 이력 먼저 + inOrder.verify(userRepository).delete(user); // 그 다음 사용자 삭제 + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceGetUserInfoTest.java b/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceGetUserInfoTest.java new file mode 100644 index 00000000..053eb5ae --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceGetUserInfoTest.java @@ -0,0 +1,80 @@ +package devkor.com.teamcback.domain.user.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.when; + +import devkor.com.teamcback.domain.bookmark.repository.BookmarkRepository; +import devkor.com.teamcback.domain.bookmark.repository.CategoryRepository; +import devkor.com.teamcback.domain.bookmark.repository.UserBookmarkLogRepository; +import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; +import devkor.com.teamcback.domain.suggestion.repository.SuggestionRepository; +import devkor.com.teamcback.domain.user.dto.response.GetUserInfoRes; +import devkor.com.teamcback.domain.user.entity.Provider; +import devkor.com.teamcback.domain.user.entity.Role; +import devkor.com.teamcback.domain.user.entity.User; +import devkor.com.teamcback.domain.user.repository.UserRepository; +import devkor.com.teamcback.domain.user.validator.AppleValidator; +import devkor.com.teamcback.domain.user.validator.GoogleValidator; +import devkor.com.teamcback.domain.user.validator.KakaoValidator; +import devkor.com.teamcback.global.jwt.JwtUtil; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class UserServiceGetUserInfoTest { + @InjectMocks + UserService userService; + + @Mock + UserRepository userRepository; + @Mock + CategoryRepository categoryRepository; + @Mock + BookmarkRepository bookmarkRepository; + @Mock + UserBookmarkLogRepository userBookmarkLogRepository; + @Mock + SuggestionRepository suggestionRepository; + @Mock + UserCharacterRepository userCharacterRepository; + @Mock + JwtUtil jwtUtil; + @Mock + KakaoValidator kakaoValidator; + @Mock + GoogleValidator googleValidator; + @Mock + AppleValidator appleValidator; + @Mock + PasswordEncoder passwordEncoder; + + @DisplayName("마이페이지: 영속화된 level 컬럼 기반 응답 + isUpgraded 리셋") + @Test + void getUserInfo() { + User user = new User("tester", "tester@test.com", Role.USER, Provider.KAKAO); + ReflectionTestUtils.setField(user, "userId", 1L); + user.updateScore(10L, true); // LEVEL2, 레벨업 직후 상태 + + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(categoryRepository.countAllByUser(user)).thenReturn(1L); + + GetUserInfoRes res = userService.getUserInfo(1L); + + assertEquals(2, res.getLevel()); + assertEquals(10L, res.getScore()); + assertEquals(0L, res.getPoint()); // updateScore는 포인트를 건드리지 않음 (적립은 aspect에서) + assertEquals(10L, res.getRemainScoreToNextLevel()); // LEVEL3 시작(20) - 10 + assertEquals(33, res.getPercent()); // 100 * (10-5) / (20-5) + assertTrue(res.isUpgraded()); + assertFalse(user.isUpgraded()); // 조회 후 리셋 + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceReleaseLoginTest.java b/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceReleaseLoginTest.java new file mode 100644 index 00000000..cd3c2b71 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/user/service/UserServiceReleaseLoginTest.java @@ -0,0 +1,79 @@ +package devkor.com.teamcback.domain.user.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import devkor.com.teamcback.domain.bookmark.repository.BookmarkRepository; +import devkor.com.teamcback.domain.bookmark.repository.CategoryRepository; +import devkor.com.teamcback.domain.bookmark.repository.UserBookmarkLogRepository; +import devkor.com.teamcback.domain.character.repository.UserCharacterRepository; +import devkor.com.teamcback.domain.notification.service.PushInstallationService; +import devkor.com.teamcback.domain.suggestion.repository.SuggestionRepository; +import devkor.com.teamcback.domain.user.dto.request.LoginUserReq; +import devkor.com.teamcback.domain.user.dto.response.LoginUserRes; +import devkor.com.teamcback.domain.user.entity.Provider; +import devkor.com.teamcback.domain.user.entity.Role; +import devkor.com.teamcback.domain.user.entity.User; +import devkor.com.teamcback.domain.user.repository.UserRepository; +import devkor.com.teamcback.domain.user.validator.AppleValidator; +import devkor.com.teamcback.domain.user.validator.GoogleValidator; +import devkor.com.teamcback.domain.user.validator.KakaoValidator; +import devkor.com.teamcback.global.jwt.JwtUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class UserServiceReleaseLoginTest { + @InjectMocks + UserService userService; + + @Mock UserRepository userRepository; + @Mock CategoryRepository categoryRepository; + @Mock BookmarkRepository bookmarkRepository; + @Mock UserBookmarkLogRepository userBookmarkLogRepository; + @Mock SuggestionRepository suggestionRepository; + @Mock UserCharacterRepository userCharacterRepository; + @Mock JwtUtil jwtUtil; + @Mock KakaoValidator kakaoValidator; + @Mock GoogleValidator googleValidator; + @Mock AppleValidator appleValidator; + @Mock PasswordEncoder passwordEncoder; + @Mock PushInstallationService pushInstallationService; + + LoginUserReq request; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(userService, "adminToken", "admin-token"); + request = new LoginUserReq(); + ReflectionTestUtils.setField(request, "provider", Provider.KAKAO); + ReflectionTestUtils.setField(request, "email", "untrusted-client@example.com"); + ReflectionTestUtils.setField(request, "token", "kakao-id-token"); + } + + @Test + void returnsLoginKeyExtractedFromVerifiedToken() { + User user = new User("member", "verified@example.com", Role.USER, Provider.KAKAO); + ReflectionTestUtils.setField(user, "userId", 3L); + when(kakaoValidator.validateToken("kakao-id-token")).thenReturn("verified@example.com"); + when(userRepository.findByEmailAndProvider("verified@example.com", Provider.KAKAO)).thenReturn(user); + when(passwordEncoder.encode(anyString())).thenReturn("encoded-code"); + when(jwtUtil.createAccessToken("3", "ROLE_USER")).thenReturn("access"); + when(jwtUtil.createRefreshToken("3", "ROLE_USER")).thenReturn("refresh"); + + LoginUserRes result = userService.releaseLogin(request); + + assertEquals("verified@example.com", result.getLoginKey()); + assertEquals("access", result.getAccessToken()); + assertEquals("refresh", result.getRefreshToken()); + verify(userRepository).findByEmailAndProvider("verified@example.com", Provider.KAKAO); + } +} diff --git a/src/test/java/devkor/com/teamcback/domain/user/validator/KakaoValidatorTest.java b/src/test/java/devkor/com/teamcback/domain/user/validator/KakaoValidatorTest.java new file mode 100644 index 00000000..7cb264a7 --- /dev/null +++ b/src/test/java/devkor/com/teamcback/domain/user/validator/KakaoValidatorTest.java @@ -0,0 +1,65 @@ +package devkor.com.teamcback.domain.user.validator; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import devkor.com.teamcback.domain.user.validator.client.KakaoClient; +import devkor.com.teamcback.global.exception.exception.GlobalException; +import devkor.com.teamcback.global.jwt.OIDC.OIDCUtil; +import devkor.com.teamcback.global.jwt.OIDC.dto.OIDCDecodePayload; +import devkor.com.teamcback.global.redis.RedisUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +class KakaoValidatorTest { + KakaoValidator kakaoValidator; + + @BeforeEach + void setUp() { + kakaoValidator = new KakaoValidator( + Mockito.mock(OIDCUtil.class), + Mockito.mock(KakaoClient.class), + Mockito.mock(RedisUtil.class) + ); + } + + @Test + void keepsEmailFromVerifiedToken() { + OIDCDecodePayload payload = new OIDCDecodePayload( + "https://kauth.kakao.com", + "client-id", + "123456789", + "member@example.com" + ); + + assertEquals("member@example.com", kakaoValidator.resolveEmail(payload)); + } + + @Test + void createsStableInternalEmailWhenKakaoEmailIsMissing() { + OIDCDecodePayload payload = new OIDCDecodePayload( + "https://kauth.kakao.com", + "client-id", + "123456789", + null + ); + + assertEquals( + "kakao_123456789@noemail.kodaero.invalid", + kakaoValidator.resolveEmail(payload) + ); + } + + @Test + void rejectsTokenWithoutEmailAndSubject() { + OIDCDecodePayload payload = new OIDCDecodePayload( + "https://kauth.kakao.com", + "client-id", + null, + null + ); + + assertThrows(GlobalException.class, () -> kakaoValidator.resolveEmail(payload)); + } +} diff --git a/src/test/java/devkor/com/teamcback/global/aop/UpdateScoreAspectTest.java b/src/test/java/devkor/com/teamcback/global/aop/UpdateScoreAspectTest.java deleted file mode 100644 index fee85b3c..00000000 --- a/src/test/java/devkor/com/teamcback/global/aop/UpdateScoreAspectTest.java +++ /dev/null @@ -1,142 +0,0 @@ -package devkor.com.teamcback.global.aop; - -import devkor.com.teamcback.domain.bookmark.dto.request.CreateBookmarkReq; -import devkor.com.teamcback.domain.bookmark.service.BookmarkService; -import devkor.com.teamcback.domain.common.LocationType; -import devkor.com.teamcback.domain.suggestion.dto.request.CreateSuggestionReq; -import devkor.com.teamcback.domain.suggestion.entity.SuggestionType; -import devkor.com.teamcback.domain.suggestion.service.SuggestionService; -import devkor.com.teamcback.domain.user.dto.response.GetUserInfoRes; -import devkor.com.teamcback.domain.user.entity.User; -import devkor.com.teamcback.domain.user.repository.UserRepository; -import devkor.com.teamcback.domain.user.service.UserService; -import devkor.com.teamcback.global.exception.exception.GlobalException; -import lombok.extern.slf4j.Slf4j; -import org.assertj.core.api.Assertions; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.test.context.junit.jupiter.SpringExtension; - -import java.util.ArrayList; -import java.util.List; - -import static devkor.com.teamcback.global.response.ResultCode.NOT_FOUND_USER; - -@Slf4j -@Disabled -@ExtendWith(SpringExtension.class) -@SpringBootTest -public class UpdateScoreAspectTest { - - @Autowired - private UserRepository userRepository; - - @Autowired - private UserService userService; - - @Autowired - private SuggestionService suggestionService; - - @Autowired - private BookmarkService bookmarkService; - - @Test - public void suggestionScoreTest() { - Long userId = 36L; // 임시로 제 계정 사용했습니다. - User user = findUser(userId); - CreateSuggestionReq req = new CreateSuggestionReq("Test제목", SuggestionType.INCONVENIENCE, "내용", "이메일"); - - // 건의함 작성 시 포인트 확인 - long beforeScore = user.getScore(); - log.info("건의함 작성 전 Score : " + beforeScore); - suggestionService.createSuggestion(userId, req, null); - - user = findUser(userId); - long afterScore = user.getScore(); - log.info("건의함 작성 후 Score : " + afterScore); - Assertions.assertThat(beforeScore+3).isEqualTo(afterScore); - - // 점수가 기준치를 넘었는지 확인 - if(user.isUpgraded()) { - log.info("레벨이 올랐습니다."); - - // 업그레이드 후 마이페이지 첫 조회 - GetUserInfoRes info = userService.getUserInfo(userId); - log.info("마이페이지 isUgraded : " + info.isUpgraded()); - Assertions.assertThat(info.isUpgraded()).isTrue(); // True - - user = findUser(userId); // 조회 후 User 정보 - log.info("조회 후 isUpgraded : " + user.isUpgraded()); - Assertions.assertThat(user.isUpgraded()).isFalse(); // false - } else { - log.info("레벨이 오르지 않았습니다."); - } - } - - @Test - public void bookmarkScoreTest() { - Long userId = 36L; // 임시로 제 계정 사용했습니다. - List list = new ArrayList<>(); - - list.add(96L); // 카테고리 ID : 새 로그 검사용 - LocationType type = LocationType.PLACE; - long placeId = 66L; - String memo = "memo"; - - User user = findUser(userId); - CreateBookmarkReq req = new CreateBookmarkReq(list, type, placeId, memo); - - // 1. 건의함 작성 시 포인트 확인 - long beforeScore = user.getScore(); - log.info("북마크 생성 전 Score : " + beforeScore); - bookmarkService.createBookmark(userId, req); - - user = findUser(userId); - long afterScore = user.getScore(); - log.info("북마크 생성 후 Score : " + afterScore); - Assertions.assertThat(beforeScore+1).isEqualTo(afterScore); - - // 2. 점수가 기준치를 넘었는지 확인 - if(user.isUpgraded()) { - log.info("레벨이 올랐습니다."); - - // 업그레이드 후 마이페이지 첫 조회 - GetUserInfoRes info = userService.getUserInfo(userId); - log.info("마이페이지 isUgraded : " + info.isUpgraded()); - Assertions.assertThat(info.isUpgraded()).isTrue(); // True - - user = findUser(userId); // 조회 후 User 정보 - log.info("조회 후 isUpgraded : " + user.isUpgraded()); - Assertions.assertThat(user.isUpgraded()).isFalse(); // false - } else { - log.info("레벨이 오르지 않았습니다."); - } - - // 3. 다른 카테고리에 동일 장소 북마크 추가 : 점수 증가X - log.info("북마크 재생성 전 Score : " + afterScore); - list.clear(); - list.add(99L); - req = new CreateBookmarkReq(list, type, placeId, memo); - bookmarkService.createBookmark(userId, req); - - user = findUser(userId); - long againScore = user.getScore(); - log.info("북마크 재생성 후 Score : " + againScore); - Assertions.assertThat(againScore).isEqualTo(afterScore); - - // 4. 동일 북마크 다시 넣었을 때 점수 증가 X - bookmarkService.createBookmark(userId, req); - - user = findUser(userId); - long dupScore = user.getScore(); - log.info("중복 북마크 생성 후 Score (저장X) : " + dupScore); - Assertions.assertThat(dupScore).isEqualTo(againScore); - } - - private User findUser(Long userId) { - return userRepository.findById(userId).orElseThrow(() -> new GlobalException(NOT_FOUND_USER)); - } -} diff --git a/src/test/java/devkor/com/teamcback/global/aop/UpdateScoreLevelTest.java b/src/test/java/devkor/com/teamcback/global/aop/UpdateScoreLevelTest.java new file mode 100644 index 00000000..710fe33f --- /dev/null +++ b/src/test/java/devkor/com/teamcback/global/aop/UpdateScoreLevelTest.java @@ -0,0 +1,68 @@ +package devkor.com.teamcback.global.aop; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import devkor.com.teamcback.domain.common.repository.FileRepository; +import devkor.com.teamcback.domain.place.repository.PlaceRepository; +import devkor.com.teamcback.domain.review.repository.ReviewRepository; +import devkor.com.teamcback.domain.suggestion.repository.SuggestionRepository; +import devkor.com.teamcback.domain.user.entity.Level; +import devkor.com.teamcback.domain.user.entity.Provider; +import devkor.com.teamcback.domain.user.entity.Role; +import devkor.com.teamcback.domain.user.entity.User; +import devkor.com.teamcback.domain.user.repository.UserRepository; +import devkor.com.teamcback.domain.vote.repository.VoteRecordRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class UpdateScoreLevelTest { + @InjectMocks + UpdateScoreAspect updateScoreAspect; + + @Mock + UserRepository userRepository; + @Mock + SuggestionRepository suggestionRepository; + @Mock + VoteRecordRepository voteRecordRepository; + @Mock + ReviewRepository reviewRepository; + @Mock + PlaceRepository placeRepository; + @Mock + FileRepository fileRepository; + + @DisplayName("점수 적립 시 레벨업 판정과 level 컬럼 갱신 + 포인트 동일 적립") + @Test + void increaseScoreUpdatesLevel() { + User user = new User("tester", "tester@test.com", Role.USER, Provider.KAKAO); + + // 건의 작성 +10점 → LEVEL1(0) → LEVEL2(5~) 레벨업, 포인트도 +10 + updateScoreAspect.increaseScore(user, 10); + assertEquals(10L, user.getScore()); + assertEquals(10L, user.getPoint()); + assertEquals(Level.LEVEL2, user.getLevel()); + assertTrue(user.isUpgraded()); + + // 같은 레벨 내 적립은 레벨업 아님 + updateScoreAspect.increaseScore(user, 5); + assertEquals(15L, user.getScore()); + assertEquals(15L, user.getPoint()); + assertEquals(Level.LEVEL2, user.getLevel()); + assertFalse(user.isUpgraded()); + + // 리뷰 최대 점수(+13)로 임계값을 건너뛰어도 레벨과 컬럼이 일치 + updateScoreAspect.increaseScore(user, 13); + assertEquals(28L, user.getScore()); + assertEquals(28L, user.getPoint()); + assertEquals(Level.LEVEL3, user.getLevel()); + assertTrue(user.isUpgraded()); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 00000000..ed69394e --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,101 @@ +spring: + cache: + type: none + datasource: + driver-class-name: org.h2.Driver + url: jdbc:h2:mem:testdb;MODE=MYSQL;DB_CLOSE_DELAY=-1 + username: sa + password: + + jpa: + open-in-view: false + hibernate: + ddl-auto: create-drop + naming: + physical-strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl + show-sql: true + properties: + hibernate: + format_sql: true + defer-datasource-initialization: false + sql: + init: + mode: never + + data: + redis: + host: localhost + port: 6379 + password: + + mail: + host: localhost + port: 25 + username: test + password: test + +jwt: + secret: + key: test-jwt-secret-key-for-testing-purposes-only-must-be-at-least-256-bits + social: + kakao: + iss: test + aud: test + google: + iss: test + aud: test + apple: + iss: test + aud: test + dev-aud: test + admin: + token: test-admin-token + +cloud: + aws: + s3: + bucket: test-bucket + credentials: + access-key: test + secret-key: test + region: + static: ap-northeast-2 + +profile: + image: + lv1-url: http://localhost/profile/lv1.jpg + lv2-url: http://localhost/profile/lv2.jpg + lv3-url: http://localhost/profile/lv3.jpg + lv4-url: http://localhost/profile/lv4.jpg + lv5-url: http://localhost/profile/lv5.jpg + +place: + default-image: + cafe: http://localhost/place/CAFE.jpg + cafeteria: http://localhost/place/CAFETERIA.jpg + convenience-store: http://localhost/place/CONVENIENCE_STORE.jpg + gym: http://localhost/place/GYM.jpg + lounge: http://localhost/place/LOUNGE.jpg + reading-room: http://localhost/place/READING_ROOM.jpg + shower-room: http://localhost/place/SHOWER_ROOM.jpg + sleeping-room: http://localhost/place/SLEEPING_ROOM.jpg + study-room: http://localhost/place/STUDY_ROOM.jpg + +date: + api: + holiday: + end-point: http://localhost/api/holiday + encoded-key: test + decoded-key: test + +metrics: + environment: test + +management: + endpoints: + web: + exposure: + include: health + +staff: + emails: test@test.com diff --git a/src/test/resources/logback-spring.xml b/src/test/resources/logback-spring.xml new file mode 100644 index 00000000..8e82bcbe --- /dev/null +++ b/src/test/resources/logback-spring.xml @@ -0,0 +1,13 @@ + + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml new file mode 100644 index 00000000..1f46bb17 --- /dev/null +++ b/src/test/resources/logback-test.xml @@ -0,0 +1,12 @@ + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + +