diff --git a/src/main/java/com/example/Tokkit_server/TokkitServerApplication.java b/src/main/java/com/example/Tokkit_server/TokkitServerApplication.java index f15a5c4..5f1420d 100644 --- a/src/main/java/com/example/Tokkit_server/TokkitServerApplication.java +++ b/src/main/java/com/example/Tokkit_server/TokkitServerApplication.java @@ -1,13 +1,17 @@ package com.example.Tokkit_server; +import java.util.TimeZone; + import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication(scanBasePackages = "com.example") +@EnableAsync @EnableJpaAuditing @EnableScheduling @EnableJpaRepositories(basePackages = "com.example") @@ -15,7 +19,7 @@ public class TokkitServerApplication { public static void main(String[] args) { + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); SpringApplication.run(TokkitServerApplication.class, args); } - } diff --git a/src/main/java/com/example/Tokkit_server/notification/service/NotificationServiceImpl.java b/src/main/java/com/example/Tokkit_server/notification/service/NotificationServiceImpl.java index 14a9891..38032b8 100644 --- a/src/main/java/com/example/Tokkit_server/notification/service/NotificationServiceImpl.java +++ b/src/main/java/com/example/Tokkit_server/notification/service/NotificationServiceImpl.java @@ -17,7 +17,9 @@ import com.example.Tokkit_server.user.utils.SseEmitters; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; @@ -76,7 +78,6 @@ public void sendNotification(User user, NotificationTemplate template, Object... notificationRepository.save(notification); } - @Transactional public SseEmitter subscribe(Long userId) { SseEmitter emitter = new SseEmitter(DEFAULT_TIMEOUT); sseEmitters.add(userId, emitter); @@ -91,9 +92,10 @@ public SseEmitter subscribe(Long userId) { sseEmitters.remove(userId); } - userRepository.findById(userId).ifPresent(this::sendUnsentNotifications); + // 트랜잭션 점유 방지를 위해 비동기로 분리된 메서드 호출 + userRepository.findById(userId).ifPresent(this::sendUnsentNotificationsAsync); - // 연결 유지용 ping + // Ping 유지 ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); scheduler.scheduleAtFixedRate(() -> { try { @@ -123,6 +125,12 @@ public SseEmitter subscribe(Long userId) { return emitter; } + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void sendUnsentNotificationsAsync(User user) { + sendUnsentNotifications(user); + } + @Transactional public void deleteNotification(Long notificationId, User user) { Notification notification = notificationRepository.findByIdAndUser(notificationId, user) diff --git a/src/main/java/com/example/Tokkit_server/store/controller/StoreController.java b/src/main/java/com/example/Tokkit_server/store/controller/StoreController.java index 83e0cd6..59eeff3 100644 --- a/src/main/java/com/example/Tokkit_server/store/controller/StoreController.java +++ b/src/main/java/com/example/Tokkit_server/store/controller/StoreController.java @@ -2,16 +2,23 @@ import com.example.Tokkit_server.global.apiPayload.ApiResponse; import com.example.Tokkit_server.store.dto.response.KakaoMapSearchResponse; +import com.example.Tokkit_server.store.dto.response.StoreBasicInfoResponseDto; import com.example.Tokkit_server.store.dto.response.StoreInfoResponse; import com.example.Tokkit_server.store.dto.response.StoreSimpleResponse; +import com.example.Tokkit_server.store.dto.response.VoucherPageResponseDto; import com.example.Tokkit_server.store.service.StoreService; import com.example.Tokkit_server.store.service.command.StoreCommandService; +import com.example.Tokkit_server.store.service.query.StoreQueryService; import com.example.Tokkit_server.user.auth.CustomUserDetails; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; 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.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -27,6 +34,7 @@ public class StoreController { private final StoreCommandService storeCommandService; private final StoreService storeService; + private final StoreQueryService storeQueryService; @GetMapping("/nearby") @Operation( @@ -69,4 +77,17 @@ public ApiResponse getStoreSimpleInfo(@RequestParam Long st StoreSimpleResponse response = storeService.getSimpleStoreInfo(storeId); return ApiResponse.onSuccess(response); } + @GetMapping("/{storeId}") + public ApiResponse getStoreInfo(@PathVariable Long storeId) { + return ApiResponse.onSuccess(storeQueryService.getStoreInfo(storeId)); + } + + @GetMapping("/{storeId}/vouchers") + public ApiResponse getAvailabㄴleVouchers( + @PathVariable Long storeId, + @AuthenticationPrincipal CustomUserDetails userDetails, + @PageableDefault(size = 5) Pageable pageable + ) { + return ApiResponse.onSuccess(storeQueryService.getAvailableVouchers(storeId, userDetails.getId(), pageable)); + } } diff --git a/src/main/java/com/example/Tokkit_server/store/dto/response/StoreBasicInfoResponseDto.java b/src/main/java/com/example/Tokkit_server/store/dto/response/StoreBasicInfoResponseDto.java new file mode 100644 index 0000000..ca9ca22 --- /dev/null +++ b/src/main/java/com/example/Tokkit_server/store/dto/response/StoreBasicInfoResponseDto.java @@ -0,0 +1,21 @@ +package com.example.Tokkit_server.store.dto.response; + +import com.example.Tokkit_server.store.entity.Store; + +public record StoreBasicInfoResponseDto( + Long storeId, + String storeName, + String category, + String address, + String postalCode +) { + public static StoreBasicInfoResponseDto from(Store store) { + return new StoreBasicInfoResponseDto( + store.getId(), + store.getStoreName(), + store.getStoreCategory().name(), + store.getRoadAddress(), + store.getNewZipcode() + ); + } +} diff --git a/src/main/java/com/example/Tokkit_server/store/dto/response/VoucherPageResponseDto.java b/src/main/java/com/example/Tokkit_server/store/dto/response/VoucherPageResponseDto.java new file mode 100644 index 0000000..6eae849 --- /dev/null +++ b/src/main/java/com/example/Tokkit_server/store/dto/response/VoucherPageResponseDto.java @@ -0,0 +1,50 @@ +package com.example.Tokkit_server.store.dto.response; + +import com.example.Tokkit_server.voucher.entity.Voucher; +import com.example.Tokkit_server.voucher_ownership.entity.VoucherOwnership; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.data.domain.Page; + +public record VoucherPageResponseDto( + List content, + int currentPage, + int pageSize, + int totalPages, + long totalElements, + boolean hasNext +) { + public static VoucherPageResponseDto from(Page page) { + List content = page.getContent().stream() + .map(VoucherInfoDto::from) + .toList(); + + return new VoucherPageResponseDto( + content, + page.getNumber(), + page.getSize(), + page.getTotalPages(), + page.getTotalElements(), + page.hasNext() + ); + } + + public record VoucherInfoDto( + Long voucherId, + String name, + LocalDate validUntil, + Long balance + ) { + public static VoucherInfoDto from(VoucherOwnership ownership) { + Voucher v = ownership.getVoucher(); + return new VoucherInfoDto( + v.getId(), + v.getName(), + v.getValidDate().toLocalDate(), + ownership.getRemainingAmount() + ); + } + } +} diff --git a/src/main/java/com/example/Tokkit_server/store/repository/StoreRepository.java b/src/main/java/com/example/Tokkit_server/store/repository/StoreRepository.java index 6e2c683..55c95ad 100644 --- a/src/main/java/com/example/Tokkit_server/store/repository/StoreRepository.java +++ b/src/main/java/com/example/Tokkit_server/store/repository/StoreRepository.java @@ -19,7 +19,7 @@ public interface StoreRepository extends JpaRepository { "FROM VoucherStore vs JOIN vs.store s " + "WHERE vs.voucher.id = :voucherId") Page findByVoucherId(@Param("voucherId") Long voucherId, Pageable pageable); - +/* @Query(value = """ SELECT @@ -48,7 +48,37 @@ List findNearbyStoresRaw( @Param("radius") double radius, @Param("category") String category, @Param("keyword") String keyword - ); + );*/ +@Query(value = """ + SELECT + s.id AS id, + s.store_name AS storeName, + s.road_address AS roadAddress, + s.new_zipcode AS newZipcode, + s.latitude AS latitude, + s.longitude AS longitude, + s.store_category AS storeCategory, + ST_Distance_Sphere(POINT(s.longitude, s.latitude), POINT(:lng, :lat)) AS distance + FROM wallet w + JOIN voucher_ownership vo ON w.id = vo.wallet_id + JOIN voucher_store vs ON vo.voucher_id = vs.voucher_id + JOIN store s ON vs.store_id = s.id + WHERE w.user_id = :userId + AND s.latitude BETWEEN :lat - (:radius / 111320) AND :lat + (:radius / 111320) + AND s.longitude BETWEEN :lng - (:radius / (111320 * COS(RADIANS(:lat)))) AND :lng + (:radius / (111320 * COS(RADIANS(:lat)))) + AND (:category IS NULL OR s.store_category = :category) + AND (:keyword IS NULL OR s.store_name LIKE CONCAT('%', :keyword, '%') OR s.road_address LIKE CONCAT('%', :keyword, '%')) + HAVING distance <= :radius + ORDER BY distance + """, nativeQuery = true) +List findNearbyStoresRaw( + @Param("userId") Long userId, + @Param("lat") double lat, + @Param("lng") double lng, + @Param("radius") double radius, + @Param("category") String category, + @Param("keyword") String keyword +); Optional findByIdAndMerchantId(Long storeId, Long merchantId); diff --git a/src/main/java/com/example/Tokkit_server/store/service/query/StoreQueryService.java b/src/main/java/com/example/Tokkit_server/store/service/query/StoreQueryService.java new file mode 100644 index 0000000..86fb71c --- /dev/null +++ b/src/main/java/com/example/Tokkit_server/store/service/query/StoreQueryService.java @@ -0,0 +1,9 @@ +package com.example.Tokkit_server.store.service.query; +import com.example.Tokkit_server.store.dto.response.StoreBasicInfoResponseDto; +import com.example.Tokkit_server.store.dto.response.VoucherPageResponseDto; + +import org.springframework.data.domain.Pageable; +public interface StoreQueryService { + StoreBasicInfoResponseDto getStoreInfo(Long storeId); + VoucherPageResponseDto getAvailableVouchers(Long storeId, Long userId, Pageable pageable); +} diff --git a/src/main/java/com/example/Tokkit_server/store/service/query/StoreQueryServiceImpl.java b/src/main/java/com/example/Tokkit_server/store/service/query/StoreQueryServiceImpl.java new file mode 100644 index 0000000..dca6bee --- /dev/null +++ b/src/main/java/com/example/Tokkit_server/store/service/query/StoreQueryServiceImpl.java @@ -0,0 +1,47 @@ +package com.example.Tokkit_server.store.service.query; + +import com.example.Tokkit_server.global.apiPayload.code.status.ErrorStatus; +import com.example.Tokkit_server.global.apiPayload.exception.GeneralException; +import com.example.Tokkit_server.store.dto.response.StoreBasicInfoResponseDto; +import com.example.Tokkit_server.store.dto.response.VoucherPageResponseDto; +import com.example.Tokkit_server.store.entity.Store; +import com.example.Tokkit_server.store.repository.StoreRepository; +import com.example.Tokkit_server.user.repository.UserRepository; +import com.example.Tokkit_server.voucher_ownership.entity.VoucherOwnership; +import com.example.Tokkit_server.voucher_ownership.repository.VoucherOwnershipRepository; + +import lombok.RequiredArgsConstructor; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class StoreQueryServiceImpl implements StoreQueryService { + + private final StoreRepository storeRepository; + private final VoucherOwnershipRepository voucherOwnershipRepository; + private final UserRepository userRepository; + + @Override + public StoreBasicInfoResponseDto getStoreInfo(Long storeId) { + Store store = storeRepository.findById(storeId) + .orElseThrow(() -> new GeneralException(ErrorStatus.STORE_NOT_FOUND)); + return StoreBasicInfoResponseDto.from(store); + } + + @Override + public VoucherPageResponseDto getAvailableVouchers(Long storeId, Long userId, Pageable pageable) { + + getStoreInfo(storeId); + + userRepository.findById(userId).orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND)); + + Page page = voucherOwnershipRepository.findAvailableVouchersByUserAndStore( + userId, storeId, pageable + ); + + return VoucherPageResponseDto.from(page); + } +} diff --git a/src/main/java/com/example/Tokkit_server/voucher_ownership/repository/VoucherOwnershipRepository.java b/src/main/java/com/example/Tokkit_server/voucher_ownership/repository/VoucherOwnershipRepository.java index 2789b1c..87848cb 100644 --- a/src/main/java/com/example/Tokkit_server/voucher_ownership/repository/VoucherOwnershipRepository.java +++ b/src/main/java/com/example/Tokkit_server/voucher_ownership/repository/VoucherOwnershipRepository.java @@ -41,4 +41,21 @@ List findByStatusAndVoucherValidDateBeforeWithFetchJoin( @Param("now") LocalDateTime now ); + @Query(""" + SELECT vo FROM VoucherOwnership vo + JOIN vo.voucher v + JOIN v.voucherStores vs + WHERE vo.wallet.user.id = :userId + AND vs.store.id = :storeId + AND vo.status = 'AVAILABLE' + AND vo.remainingAmount > 0 + AND v.validDate >= CURRENT_TIMESTAMP + ORDER BY vo.remainingAmount DESC,v.validDate ASC + """) + Page findAvailableVouchersByUserAndStore( + @Param("userId") Long userId, + + @Param("storeId") Long storeId, + Pageable pageable + ); }