From 1e9df07245975ade4211ae6b67fbda48a0baff3b Mon Sep 17 00:00:00 2001 From: sengjun0624 Date: Thu, 29 May 2025 16:25:34 +0900 Subject: [PATCH 01/34] :sparkles: feat: Add StoreDetail Api --- .../store/controller/StoreController.java | 21 +++++++++ .../response/StoreBasicInfoResponseDto.java | 21 +++++++++ .../service/query/StoreQueryService.java | 9 ++++ .../service/query/StoreQueryServiceImpl.java | 47 +++++++++++++++++++ 4 files changed, 98 insertions(+) create mode 100644 src/main/java/com/example/Tokkit_server/store/dto/response/StoreBasicInfoResponseDto.java create mode 100644 src/main/java/com/example/Tokkit_server/store/service/query/StoreQueryService.java create mode 100644 src/main/java/com/example/Tokkit_server/store/service/query/StoreQueryServiceImpl.java 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..7d3ccbc 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 getAvailableVouchers( + @PathVariable Long storeId, + @RequestParam Long userId, + @PageableDefault(size = 5) Pageable pageable + ) { + return ApiResponse.onSuccess(storeQueryService.getAvailableVouchers(storeId, userId, 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/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..8fece6a --- /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) { + + storeRepository.findById(storeId).orElseThrow(() -> new GeneralException(ErrorStatus.STORE_NOT_FOUND)); + + userRepository.findById(userId).orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND)); + + Page page = voucherOwnershipRepository.findAvailableVouchersByUserAndStore( + userId, storeId, pageable + ); + + return VoucherPageResponseDto.from(page); + } +} From b43c30b9188e9e0b2803cc7b919303e79ff11094 Mon Sep 17 00:00:00 2001 From: sengjun0624 Date: Thu, 29 May 2025 16:25:43 +0900 Subject: [PATCH 02/34] :sparkles: feat: Add StoreDetail Voucher Api --- .../dto/response/VoucherPageResponseDto.java | 50 +++++++++++++++++++ .../VoucherOwnershipRepository.java | 17 +++++++ 2 files changed, 67 insertions(+) create mode 100644 src/main/java/com/example/Tokkit_server/store/dto/response/VoucherPageResponseDto.java 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/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 + ); } From a86c5402ba93cf7aa426872599fb61d54d722923 Mon Sep 17 00:00:00 2001 From: sengjun0624 Date: Thu, 29 May 2025 16:29:56 +0900 Subject: [PATCH 03/34] :sparkles: feat: Set TimeZone KR On Boot --- .../com/example/Tokkit_server/TokkitServerApplication.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/example/Tokkit_server/TokkitServerApplication.java b/src/main/java/com/example/Tokkit_server/TokkitServerApplication.java index f15a5c4..8ada9be 100644 --- a/src/main/java/com/example/Tokkit_server/TokkitServerApplication.java +++ b/src/main/java/com/example/Tokkit_server/TokkitServerApplication.java @@ -1,5 +1,7 @@ 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; @@ -15,7 +17,7 @@ public class TokkitServerApplication { public static void main(String[] args) { + TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul")); SpringApplication.run(TokkitServerApplication.class, args); } - } From 3382886fe3a2814060982e346a695d90997e9c3a Mon Sep 17 00:00:00 2001 From: sengjun0624 Date: Thu, 29 May 2025 17:55:10 +0900 Subject: [PATCH 04/34] =?UTF-8?q?:sparkles:=20feat:=20=EC=B9=B4=EC=B9=B4?= =?UTF-8?q?=EC=98=A4=EB=A7=B5=20=EC=A1=B0=ED=9A=8C=20API=20=EC=B5=9C?= =?UTF-8?q?=EC=A0=81=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../store/controller/StoreController.java | 2 +- .../store/repository/StoreRepository.java | 34 +++++++++++++++++-- 2 files changed, 33 insertions(+), 3 deletions(-) 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 7d3ccbc..f28ecf8 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 @@ -83,7 +83,7 @@ public ApiResponse getStoreInfo(@PathVariable Long st } @GetMapping("/{storeId}/vouchers") - public ApiResponse getAvailableVouchers( + public ApiResponse getAvailabㄴleVouchers( @PathVariable Long storeId, @RequestParam Long userId, @PageableDefault(size = 5) Pageable pageable 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); From 5b7a308c6d5878981859f32aaea64f1aaf3c8d52 Mon Sep 17 00:00:00 2001 From: noeyoes <> Date: Thu, 29 May 2025 20:09:10 +0900 Subject: [PATCH 05/34] fix: HikariCP Pool size error --- .../Tokkit_server/TokkitServerApplication.java | 2 ++ .../service/NotificationServiceImpl.java | 14 +++++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/example/Tokkit_server/TokkitServerApplication.java b/src/main/java/com/example/Tokkit_server/TokkitServerApplication.java index f15a5c4..e0e42ab 100644 --- a/src/main/java/com/example/Tokkit_server/TokkitServerApplication.java +++ b/src/main/java/com/example/Tokkit_server/TokkitServerApplication.java @@ -5,9 +5,11 @@ 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") 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) From 9a5ab9492d07a28aec446b32f2c90957d034619f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=8A=B9=EC=A4=80?= <105282117+sengjun0624@users.noreply.github.com> Date: Fri, 30 May 2025 09:16:00 +0900 Subject: [PATCH 06/34] Update src/main/java/com/example/Tokkit_server/store/service/query/StoreQueryServiceImpl.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../store/service/query/StoreQueryServiceImpl.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 8fece6a..dca6bee 100644 --- 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 @@ -34,7 +34,7 @@ public StoreBasicInfoResponseDto getStoreInfo(Long storeId) { @Override public VoucherPageResponseDto getAvailableVouchers(Long storeId, Long userId, Pageable pageable) { - storeRepository.findById(storeId).orElseThrow(() -> new GeneralException(ErrorStatus.STORE_NOT_FOUND)); + getStoreInfo(storeId); userRepository.findById(userId).orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND)); From 037c37e515f046e860b01952a47e8a47108a4fc0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=8A=B9=EC=A4=80?= <105282117+sengjun0624@users.noreply.github.com> Date: Fri, 30 May 2025 09:17:39 +0900 Subject: [PATCH 07/34] Update src/main/java/com/example/Tokkit_server/store/controller/StoreController.java Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Tokkit_server/store/controller/StoreController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 f28ecf8..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 @@ -85,9 +85,9 @@ public ApiResponse getStoreInfo(@PathVariable Long st @GetMapping("/{storeId}/vouchers") public ApiResponse getAvailabㄴleVouchers( @PathVariable Long storeId, - @RequestParam Long userId, + @AuthenticationPrincipal CustomUserDetails userDetails, @PageableDefault(size = 5) Pageable pageable ) { - return ApiResponse.onSuccess(storeQueryService.getAvailableVouchers(storeId, userId, pageable)); + return ApiResponse.onSuccess(storeQueryService.getAvailableVouchers(storeId, userDetails.getId(), pageable)); } } From 68801c49846ce78bce1604eb545dd2090d165c28 Mon Sep 17 00:00:00 2001 From: YoungjaeRo Date: Fri, 30 May 2025 21:15:30 +0900 Subject: [PATCH 08/34] =?UTF-8?q?feat=20:=20=EC=9E=90=EB=8F=99=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EC=9A=94=EC=B2=AD=20DTO=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/request/AutoConvertSettingRequest.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 src/main/java/com/example/Tokkit_server/wallet/dto/request/AutoConvertSettingRequest.java diff --git a/src/main/java/com/example/Tokkit_server/wallet/dto/request/AutoConvertSettingRequest.java b/src/main/java/com/example/Tokkit_server/wallet/dto/request/AutoConvertSettingRequest.java new file mode 100644 index 0000000..83434aa --- /dev/null +++ b/src/main/java/com/example/Tokkit_server/wallet/dto/request/AutoConvertSettingRequest.java @@ -0,0 +1,16 @@ +package com.example.Tokkit_server.wallet.dto.request; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@NoArgsConstructor +@AllArgsConstructor +@Getter +public class AutoConvertSettingRequest { + private boolean enabled; + private int dayOfMonth; + private int hour; + private int minute; + private long amount; +} \ No newline at end of file From 2e9b748e26911324cedd5846bba11a5762a10082 Mon Sep 17 00:00:00 2001 From: YoungjaeRo Date: Fri, 30 May 2025 21:15:39 +0900 Subject: [PATCH 09/34] =?UTF-8?q?feat=20:=20=EC=9E=90=EB=8F=99=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EC=9D=91=EB=8B=B5=20DTO=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/AutoConvertSettingResponse.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/main/java/com/example/Tokkit_server/wallet/dto/response/AutoConvertSettingResponse.java diff --git a/src/main/java/com/example/Tokkit_server/wallet/dto/response/AutoConvertSettingResponse.java b/src/main/java/com/example/Tokkit_server/wallet/dto/response/AutoConvertSettingResponse.java new file mode 100644 index 0000000..b2ab8a0 --- /dev/null +++ b/src/main/java/com/example/Tokkit_server/wallet/dto/response/AutoConvertSettingResponse.java @@ -0,0 +1,15 @@ +package com.example.Tokkit_server.wallet.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class AutoConvertSettingResponse { + private boolean enabled; + private int dayOfMonth; + private int hour; + private long amount; +} \ No newline at end of file From cb6d5c34873c6839e6635f8c1e2cbde82b6675b2 Mon Sep 17 00:00:00 2001 From: YoungjaeRo Date: Fri, 30 May 2025 21:16:05 +0900 Subject: [PATCH 10/34] =?UTF-8?q?refactor=20:=20=EA=B1=B0=EB=9E=98=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20(=EC=9E=90=EB=8F=99=20=EC=A0=84=ED=99=98)?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Tokkit_server/transaction/enums/TransactionType.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/example/Tokkit_server/transaction/enums/TransactionType.java b/src/main/java/com/example/Tokkit_server/transaction/enums/TransactionType.java index 17da751..eb6954b 100644 --- a/src/main/java/com/example/Tokkit_server/transaction/enums/TransactionType.java +++ b/src/main/java/com/example/Tokkit_server/transaction/enums/TransactionType.java @@ -1,5 +1,5 @@ package com.example.Tokkit_server.transaction.enums; public enum TransactionType { - DEPOSIT, WITHDRAW, CONVERT, PURCHASE, REFUND, RECEIVE + DEPOSIT, WITHDRAW, CONVERT, PURCHASE, REFUND, RECEIVE, AUTO_CONVERT } From 4996100ef5a7314fd4ead2708003d69047bd2c13 Mon Sep 17 00:00:00 2001 From: YoungjaeRo Date: Fri, 30 May 2025 21:16:50 +0900 Subject: [PATCH 11/34] =?UTF-8?q?refactor=20:=20=EA=B1=B0=EB=9E=98?= =?UTF-8?q?=ED=83=80=EC=9E=85=20=EB=AC=B8=EC=9E=90=EC=97=B4=20=EA=B8=B8?= =?UTF-8?q?=EC=9D=B4=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../example/Tokkit_server/transaction/entity/Transaction.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/example/Tokkit_server/transaction/entity/Transaction.java b/src/main/java/com/example/Tokkit_server/transaction/entity/Transaction.java index c0bafd2..5bf10ab 100644 --- a/src/main/java/com/example/Tokkit_server/transaction/entity/Transaction.java +++ b/src/main/java/com/example/Tokkit_server/transaction/entity/Transaction.java @@ -44,6 +44,7 @@ public class Transaction extends BaseTimeEntity { private Wallet wallet; @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false, length = 50) private TransactionType type; } From 81431254402764903374ccd8c0e2374aeb8435c7 Mon Sep 17 00:00:00 2001 From: YoungjaeRo Date: Fri, 30 May 2025 21:17:27 +0900 Subject: [PATCH 12/34] =?UTF-8?q?refactor=20:=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=EC=97=90=20=ED=95=84=EC=9A=94=ED=95=9C=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80=20(=EC=A0=84=ED=99=98?= =?UTF-8?q?=EC=8B=A0=EC=B2=AD=20=EC=97=AC=EB=B6=80,=20=EC=9D=BC,=20?= =?UTF-8?q?=EC=8B=9C,=20=EB=B6=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Tokkit_server/wallet/entity/Wallet.java | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/main/java/com/example/Tokkit_server/wallet/entity/Wallet.java b/src/main/java/com/example/Tokkit_server/wallet/entity/Wallet.java index 7e16cc5..08273c0 100644 --- a/src/main/java/com/example/Tokkit_server/wallet/entity/Wallet.java +++ b/src/main/java/com/example/Tokkit_server/wallet/entity/Wallet.java @@ -22,6 +22,7 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.Setter; @Entity @Getter @@ -57,12 +58,35 @@ public class Wallet extends BaseTimeEntity { @Column(nullable = false) private WalletType walletType; + @Column(nullable = false) + private boolean autoConvertEnabled = false; // 자동 전환 on/off + + @Column(nullable = true) + private Integer autoConvertDayOfMonth; // 1 ~ 31 + + @Column(nullable = true) + private Integer autoConvertHour; // 0 ~ 23 + + @Column + private Integer autoConvertMinute; // 0 ~ 59 + + @Column(nullable = true) + private Long autoConvertAmount; // 전환금액 + public void updateBalance(Long deposit, Long token) { this.depositBalance = deposit; this.tokenBalance = token; } + public void updateAutoConvertSetting(boolean enabled, int dayOfMonth, int hour, int minute, long amount) { + this.autoConvertEnabled = enabled; + this.autoConvertDayOfMonth = dayOfMonth; + this.autoConvertHour = hour; + this.autoConvertMinute = minute; + this.autoConvertAmount = amount; + } + /** * Wallet의 주인이 주인일수도 있고 가맹점주일수도 있기 때문에, null 이거나 둘 다의 값이 들어가는 경우를 방지 (유효성 검증) */ From dc979913a731b19d4e79f63bc08aa85dadc4b036 Mon Sep 17 00:00:00 2001 From: YoungjaeRo Date: Fri, 30 May 2025 21:17:50 +0900 Subject: [PATCH 13/34] =?UTF-8?q?feat=20:=20=EC=9E=90=EB=8F=99=EC=A0=84?= =?UTF-8?q?=ED=99=98=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../wallet/controller/WalletController.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/java/com/example/Tokkit_server/wallet/controller/WalletController.java b/src/main/java/com/example/Tokkit_server/wallet/controller/WalletController.java index d92e490..fe12372 100644 --- a/src/main/java/com/example/Tokkit_server/wallet/controller/WalletController.java +++ b/src/main/java/com/example/Tokkit_server/wallet/controller/WalletController.java @@ -1,11 +1,14 @@ package com.example.Tokkit_server.wallet.controller; import com.example.Tokkit_server.global.apiPayload.ApiResponse; +import com.example.Tokkit_server.global.apiPayload.code.status.ErrorStatus; +import com.example.Tokkit_server.global.apiPayload.exception.GeneralException; import com.example.Tokkit_server.global.util.IdempotencyManager; import com.example.Tokkit_server.user.auth.CustomUserDetails; import com.example.Tokkit_server.voucher_ownership.dto.request.VoucherPaymentRequest; import com.example.Tokkit_server.wallet.dto.request.*; import com.example.Tokkit_server.wallet.dto.response.*; +import com.example.Tokkit_server.wallet.entity.Wallet; import com.example.Tokkit_server.wallet.service.command.WalletCommandService; import com.example.Tokkit_server.wallet.service.query.BlockchainQueryService; import com.example.Tokkit_server.wallet.service.query.WalletQueryService; @@ -114,4 +117,13 @@ public ApiResponse> getPaymentOptions( return ApiResponse.onSuccess(commandService.getPaymentOptions(userDetails.getId(), storeId)); } + @PutMapping("/auto-convert") + @Operation(summary = "자동 전환 설정", description = "예금 → 토큰 자동 전환 설정을 저장합니다.") + public ApiResponse updateAutoConvertSetting( + @AuthenticationPrincipal CustomUserDetails userDetails, + @RequestBody AutoConvertSettingRequest request) + {queryService.updateAutoConvertSetting(userDetails.getId(), request); + return ApiResponse.onSuccess(null); + } + } \ No newline at end of file From eceb4cad1ee325436055f640edc6e8b7f83819f9 Mon Sep 17 00:00:00 2001 From: YoungjaeRo Date: Fri, 30 May 2025 21:18:27 +0900 Subject: [PATCH 14/34] =?UTF-8?q?feat=20:=20=EC=A0=84=ED=99=98=20=EC=98=88?= =?UTF-8?q?=EC=95=BD=20=EB=93=B1=EB=A1=9D=20(=EC=97=85=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8)=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/query/WalletQueryService.java | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/example/Tokkit_server/wallet/service/query/WalletQueryService.java b/src/main/java/com/example/Tokkit_server/wallet/service/query/WalletQueryService.java index cb998bd..5865e6e 100644 --- a/src/main/java/com/example/Tokkit_server/wallet/service/query/WalletQueryService.java +++ b/src/main/java/com/example/Tokkit_server/wallet/service/query/WalletQueryService.java @@ -11,6 +11,7 @@ import com.example.Tokkit_server.transaction.service.query.TransactionLogService; import com.example.Tokkit_server.user.entity.User; import com.example.Tokkit_server.user.repository.UserRepository; +import com.example.Tokkit_server.wallet.dto.request.AutoConvertSettingRequest; import com.example.Tokkit_server.wallet.dto.request.DepositToTokenRequest; import com.example.Tokkit_server.wallet.dto.request.TokenToDepositRequest; import com.example.Tokkit_server.wallet.dto.response.TransactionDetailResponse; @@ -25,7 +26,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.web3j.protocol.core.methods.response.TransactionReceipt; - import java.math.BigInteger; import java.util.List; @@ -227,4 +227,20 @@ public TransactionDetailResponse getTransactionDetail(Long transactionId) { transaction.getTxHash() ); } + + /** + * 변환 예약 등록 + */ + @Transactional + public void updateAutoConvertSetting(Long userId, AutoConvertSettingRequest request) { + Wallet wallet = walletRepository.findByUser_Id(userId) + .orElseThrow(() -> new GeneralException(ErrorStatus.USER_WALLET_NOT_FOUND)); + wallet.updateAutoConvertSetting( + request.isEnabled(), + request.getDayOfMonth(), + request.getHour(), + request.getMinute(), + request.getAmount() + ); + } } \ No newline at end of file From 300db898fffcdff54f94a8a06c4ac87bf605ee5c Mon Sep 17 00:00:00 2001 From: YoungjaeRo Date: Fri, 30 May 2025 21:18:55 +0900 Subject: [PATCH 15/34] =?UTF-8?q?feat=20:=20=EC=9E=90=EB=8F=99=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EC=98=88=EC=95=BD=ED=95=9C=20wallet=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20=EC=A1=B0=ED=9A=8C=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Tokkit_server/wallet/repository/WalletRepository.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/com/example/Tokkit_server/wallet/repository/WalletRepository.java b/src/main/java/com/example/Tokkit_server/wallet/repository/WalletRepository.java index a4ff794..f73b2f4 100644 --- a/src/main/java/com/example/Tokkit_server/wallet/repository/WalletRepository.java +++ b/src/main/java/com/example/Tokkit_server/wallet/repository/WalletRepository.java @@ -1,5 +1,6 @@ package com.example.Tokkit_server.wallet.repository; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -20,4 +21,6 @@ public interface WalletRepository extends JpaRepository { boolean existsByUserId(Long userId); boolean existsByMerchantId(Long merchantId); + + List findByAutoConvertEnabledTrue(); } \ No newline at end of file From 9db2e0f8e605777c9b7504ff1afb49095092db96 Mon Sep 17 00:00:00 2001 From: YoungjaeRo Date: Fri, 30 May 2025 21:19:12 +0900 Subject: [PATCH 16/34] =?UTF-8?q?feat=20:=20=EC=9E=90=EB=8F=99=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../scheduler/AutoTokenConvertScheduler.java | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 src/main/java/com/example/Tokkit_server/wallet/scheduler/AutoTokenConvertScheduler.java diff --git a/src/main/java/com/example/Tokkit_server/wallet/scheduler/AutoTokenConvertScheduler.java b/src/main/java/com/example/Tokkit_server/wallet/scheduler/AutoTokenConvertScheduler.java new file mode 100644 index 0000000..b4f8c71 --- /dev/null +++ b/src/main/java/com/example/Tokkit_server/wallet/scheduler/AutoTokenConvertScheduler.java @@ -0,0 +1,125 @@ +package com.example.Tokkit_server.wallet.scheduler; + +import java.math.BigInteger; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +import org.slf4j.MDC; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.web3j.protocol.core.methods.response.TransactionReceipt; + +import com.example.Tokkit_server.transaction.entity.Transaction; +import com.example.Tokkit_server.transaction.enums.TransactionStatus; +import com.example.Tokkit_server.transaction.enums.TransactionType; +import com.example.Tokkit_server.transaction.service.query.TransactionLogService; +import com.example.Tokkit_server.wallet.entity.Wallet; +import com.example.Tokkit_server.wallet.repository.WalletRepository; +import com.example.contract.service.TokkitTokenService; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AutoTokenConvertScheduler { + + private final WalletRepository walletRepository; + private final TokkitTokenService tokkitTokenService; + private final TransactionLogService transactionLogService; + private final RedisTemplate redisTemplate; + + /** + * txHash 있는 확장형 + */ + private void logAndSave(Wallet wallet, Long userId, Long merchantId, + TransactionType type, TransactionStatus status, Long amount, String description, String displayDescription, String txHash) { + + String traceId = MDC.get("traceId"); + if (traceId == null) { + traceId = UUID.randomUUID().toString(); + MDC.put("traceId", traceId); + } + + transactionLogService.logAndSave( + Transaction.builder() + .wallet(wallet) + .type(type) + .status(status) + .amount(amount) + .txHash(txHash) + .description(description) + .displayDescription(displayDescription) + .traceId(traceId) + .build(), + userId, + merchantId + ); + } + + @Scheduled(fixedRate = 60000) // 매 분 실행 (스프링 쪽에서 이걸 스캔한다는 뜻) + @Transactional + public void runMonthlyAutoConversion() { + MDC.put("traceId", UUID.randomUUID().toString()); + LocalDateTime now = LocalDateTime.now(); + int nowDay = now.getDayOfMonth(); + int nowHour = now.getHour(); + int nowMinute = now.getMinute(); + + List wallets = walletRepository.findByAutoConvertEnabledTrue(); + + for (Wallet wallet : wallets) { + if (!wallet.isAutoConvertEnabled()) continue; + if (!Objects.equals(wallet.getAutoConvertDayOfMonth(), nowDay)) continue; + if (!Objects.equals(wallet.getAutoConvertHour(), nowHour)) continue; + if (!Objects.equals(wallet.getAutoConvertMinute(), nowMinute)) continue; + + String lockKey = "auto-convert-lock:" + wallet.getId(); + // Redis 락 획득 + Boolean lockAcquired = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofMinutes(10)); + + if (Boolean.FALSE.equals(lockAcquired)) { + log.info(" 이미 다른 인스턴스에서 처리 중인 Wallet: {}", wallet.getId()); + continue; + } + + try { + long amount = wallet.getAutoConvertAmount(); + if (amount <= 0 || wallet.getDepositBalance() < amount) continue; + + wallet.updateBalance( + wallet.getDepositBalance() - amount, + wallet.getTokenBalance() + amount + ); + + TransactionReceipt receipt = tokkitTokenService.mint( + wallet.getWalletAddress(), + BigInteger.valueOf(amount) + ); + + logAndSave( + wallet, + wallet.getUser().getId(), + null, + TransactionType.AUTO_CONVERT, + TransactionStatus.SUCCESS, + amount, + "정기 자동 예금 → 토큰 전환", + "자동 충전 실행", + receipt.getTransactionHash() + ); + + log.info("자동 전환 완료: userId={}, amount={}, txHash={}", + wallet.getUser().getId(), amount, receipt.getTransactionHash()); + + } catch (Exception e) { + log.error("자동 전환 실패: userId={}", wallet.getUser().getId(), e); + } + } + } +} \ No newline at end of file From 383b6993e2542f11515b527f3878e6c6700f66a2 Mon Sep 17 00:00:00 2001 From: noeyoes <> Date: Sat, 31 May 2025 12:39:27 +0900 Subject: [PATCH 17/34] =?UTF-8?q?feat:=20=EA=B0=80=EB=A7=B9=EC=A0=90?= =?UTF-8?q?=EC=A3=BC=20=EC=95=8C=EB=A6=BC=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../merchant/auth/CustomMerchantDetails.java | 11 ++ .../merchant/service/MerchantService.java | 11 ++ .../MerchantNotificationController.java | 61 ++++++ .../MerchantNotificationResponseDto.java | 30 +++ .../entity/MerchantNotification.java | 51 +++++ .../MerchantNotificationRepository.java | 23 +++ .../service/MerchantNotificationService.java | 175 ++++++++++++++++++ .../service/NotificationService.java | 3 - .../service/NotificationServiceImpl.java | 33 ---- 9 files changed, 362 insertions(+), 36 deletions(-) create mode 100644 src/main/java/com/example/Tokkit_server/notification/controller/MerchantNotificationController.java create mode 100644 src/main/java/com/example/Tokkit_server/notification/dto/response/MerchantNotificationResponseDto.java create mode 100644 src/main/java/com/example/Tokkit_server/notification/entity/MerchantNotification.java create mode 100644 src/main/java/com/example/Tokkit_server/notification/repository/MerchantNotificationRepository.java create mode 100644 src/main/java/com/example/Tokkit_server/notification/service/MerchantNotificationService.java diff --git a/src/main/java/com/example/Tokkit_server/merchant/auth/CustomMerchantDetails.java b/src/main/java/com/example/Tokkit_server/merchant/auth/CustomMerchantDetails.java index ae8102f..544b3af 100644 --- a/src/main/java/com/example/Tokkit_server/merchant/auth/CustomMerchantDetails.java +++ b/src/main/java/com/example/Tokkit_server/merchant/auth/CustomMerchantDetails.java @@ -1,5 +1,6 @@ package com.example.Tokkit_server.merchant.auth; +import com.example.Tokkit_server.merchant.entity.Merchant; import lombok.Getter; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; @@ -59,4 +60,14 @@ public String getUsername() { @Override public boolean isEnabled() { return true; } + + public Merchant toMerchant() { + return Merchant.builder() + .id(this.id) + .businessNumber(this.businessNumber) + .email(this.email) + .password(this.password) + .roles(this.roles) + .build(); + } } diff --git a/src/main/java/com/example/Tokkit_server/merchant/service/MerchantService.java b/src/main/java/com/example/Tokkit_server/merchant/service/MerchantService.java index 0934944..d565c84 100644 --- a/src/main/java/com/example/Tokkit_server/merchant/service/MerchantService.java +++ b/src/main/java/com/example/Tokkit_server/merchant/service/MerchantService.java @@ -11,6 +11,8 @@ import com.example.Tokkit_server.merchant.entity.MerchantEmailValidation; import com.example.Tokkit_server.merchant.repository.MerchantEmailValidationRepository; import com.example.Tokkit_server.merchant.repository.MerchantRepository; +import com.example.Tokkit_server.notification.entity.MerchantNotificationCategorySetting; +import com.example.Tokkit_server.notification.enums.NotificationCategory; import com.example.Tokkit_server.ocr.service.KakaoAddressSearchService; import com.example.Tokkit_server.ocr.utils.KakaoGeoResult; import com.example.Tokkit_server.region.entity.Region; @@ -104,6 +106,15 @@ public MerchantRegisterResponseDto createMerchant(CreateMerchantRequestDto reque Wallet wallet = walletCommandService.createInitialWalletForMerchant(merchant.getId()); merchant.setWallet(wallet); + // 9. 알림 설정 + for (NotificationCategory category : NotificationCategory.values()) { + MerchantNotificationCategorySetting setting = MerchantNotificationCategorySetting.builder() + .merchant(merchant) + .category(category) + .enabled(true) + .build(); + } + return MerchantRegisterResponseDto.from(merchant); } diff --git a/src/main/java/com/example/Tokkit_server/notification/controller/MerchantNotificationController.java b/src/main/java/com/example/Tokkit_server/notification/controller/MerchantNotificationController.java new file mode 100644 index 0000000..ecb7ca5 --- /dev/null +++ b/src/main/java/com/example/Tokkit_server/notification/controller/MerchantNotificationController.java @@ -0,0 +1,61 @@ +package com.example.Tokkit_server.notification.controller; + +import com.example.Tokkit_server.global.apiPayload.ApiResponse; +import com.example.Tokkit_server.global.apiPayload.code.status.SuccessStatus; +import com.example.Tokkit_server.merchant.auth.CustomMerchantDetails; +import com.example.Tokkit_server.notification.dto.response.MerchantNotificationResponseDto; +import com.example.Tokkit_server.notification.enums.NotificationCategory; +import com.example.Tokkit_server.notification.service.MerchantNotificationService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +import java.util.List; + +@RestController +@RequestMapping("/api/merchants/notifications") +@RequiredArgsConstructor +@Tag(name = "Merchant Notification", description = "가맹점주 알림 관련 API입니다.") +public class MerchantNotificationController { + + private final MerchantNotificationService notificationService; + + @GetMapping + @Operation(summary = "알림 전체 목록 조회", description = "가맹점주가 설정한 카테고리에 맞춰 전체 알림 목록을 조회합니다.") + public ApiResponse> getAllNotifications(@AuthenticationPrincipal CustomMerchantDetails merchantDetails) { + return ApiResponse.onSuccess(notificationService.getAllNotifications(merchantDetails.toMerchant())); + } + + @GetMapping("/category") + @Operation(summary = "알림 카테고리별 목록 조회", description = "가맹점주가 설정한 카테고리에 맞춰 카테고리별 알림 목록을 조회합니다.") + public ApiResponse> getNotificationsByCategory( + @AuthenticationPrincipal CustomMerchantDetails merchantDetails, + @RequestParam NotificationCategory category) { + return ApiResponse.onSuccess(notificationService.getMerchantNotificationsByCategory(merchantDetails.toMerchant(), category)); + } + + @DeleteMapping("/{notificationId}") + @Operation(summary = "알림 삭제", description = "알림을 삭제합니다.") + public ApiResponse deleteNotification( + @AuthenticationPrincipal CustomMerchantDetails merchantDetails, + @PathVariable Long notificationId) { + notificationService.deleteNotification(notificationId, merchantDetails.toMerchant()); + return ApiResponse.onSuccess(SuccessStatus.NOTIFICATION_DELETE_OK); + } + + @GetMapping(value = "/subscribe", produces = "text/event-stream;charset=UTF-8") + @Operation(summary = "알림 구독", description = "가맹점주가 설정한 카테고리에 맞게 SSE 알림을 구독 상태로 만들어줍니다.") + public SseEmitter subscribe(@AuthenticationPrincipal CustomMerchantDetails merchantDetails) { + return notificationService.subscribe(merchantDetails.getId()); + } + + @PostMapping("/send") + @Operation(summary = "모든 알림 전송", description = "가맹점주가 설정한 카테고리에 맞게 SSE 알림과 이메일 알림을 보냅니다.") + public ApiResponse sendUnsentNotifications(@AuthenticationPrincipal CustomMerchantDetails merchantDetails) { + notificationService.sendUnsentMerchantNotifications(merchantDetails.toMerchant()); + return ApiResponse.onSuccess(SuccessStatus.NOTIFICATION_SEND_OK); + } +} diff --git a/src/main/java/com/example/Tokkit_server/notification/dto/response/MerchantNotificationResponseDto.java b/src/main/java/com/example/Tokkit_server/notification/dto/response/MerchantNotificationResponseDto.java new file mode 100644 index 0000000..a193e91 --- /dev/null +++ b/src/main/java/com/example/Tokkit_server/notification/dto/response/MerchantNotificationResponseDto.java @@ -0,0 +1,30 @@ +package com.example.Tokkit_server.notification.dto.response; + +import com.example.Tokkit_server.notification.entity.MerchantNotification; +import com.example.Tokkit_server.notification.enums.NotificationCategory; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; + +@Getter +@Builder +public class MerchantNotificationResponseDto { + private Long id; + private String title; + private String content; + private NotificationCategory category; + private String deleted; + private LocalDateTime createdAt; + + public static MerchantNotificationResponseDto from(MerchantNotification notification) { + return MerchantNotificationResponseDto.builder() + .id(notification.getId()) + .title(notification.getTitle()) + .content(notification.getContent()) + .category(notification.getCategory()) + .deleted(notification.isDeleted() ? "deleted" : "active") + .createdAt(notification.getCreatedAt()) + .build(); + } +} diff --git a/src/main/java/com/example/Tokkit_server/notification/entity/MerchantNotification.java b/src/main/java/com/example/Tokkit_server/notification/entity/MerchantNotification.java new file mode 100644 index 0000000..5460a51 --- /dev/null +++ b/src/main/java/com/example/Tokkit_server/notification/entity/MerchantNotification.java @@ -0,0 +1,51 @@ +package com.example.Tokkit_server.notification.entity; + +import com.example.Tokkit_server.global.entity.BaseTimeEntity; +import com.example.Tokkit_server.merchant.entity.Merchant; +import com.example.Tokkit_server.notification.enums.NotificationCategory; +import com.example.Tokkit_server.user.entity.User; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class MerchantNotification extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "merchant_id", nullable = false) + private Merchant merchant; + + @Enumerated(EnumType.STRING) + private NotificationCategory category; + + private String title; + + private String content; + + private boolean deleted; + + @Column(nullable = false) + private boolean sentSse; + + @Column(nullable = false) + private boolean sentMail; + + // soft delete 용 메서드 + public void softDelete() { + this.deleted = true; + } + + // SSE 알림 발송 여부 확인용 메서드 + public void markAsSentSse() { + this.sentSse = true; + } + + // Mail 알림 발송 여부 확인용 메서드 + public void markAsSentMail() { this.sentMail = true; } +} diff --git a/src/main/java/com/example/Tokkit_server/notification/repository/MerchantNotificationRepository.java b/src/main/java/com/example/Tokkit_server/notification/repository/MerchantNotificationRepository.java new file mode 100644 index 0000000..fcd2c1d --- /dev/null +++ b/src/main/java/com/example/Tokkit_server/notification/repository/MerchantNotificationRepository.java @@ -0,0 +1,23 @@ +package com.example.Tokkit_server.notification.repository; + +import com.example.Tokkit_server.merchant.entity.Merchant; +import com.example.Tokkit_server.notification.entity.MerchantNotification; +import com.example.Tokkit_server.notification.enums.NotificationCategory; +import io.lettuce.core.dynamic.annotation.Param; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; +import java.util.Optional; + +public interface MerchantNotificationRepository extends JpaRepository { + @Query("SELECT n FROM MerchantNotification n WHERE n.merchant = :merchant AND n.category IN :categories AND n.deleted = false") + List findByMerchantAndCategoriesAndDeletedFalse(@Param("merchant") Merchant merchant, @Param("categories")List categories); + + @Query("SELECT n FROM MerchantNotification n WHERE n.merchant = :merchant AND n.category = :category AND n.deleted = false") + List findByMerchantAndCategoryAndDeletedFalse(@Param("merchant") Merchant merchant, @Param("category") NotificationCategory category); + + Optional findByIdAndMerchant(Long id, Merchant merchant); + + List findByMerchantAndDeletedFalse(Merchant merchant); +} diff --git a/src/main/java/com/example/Tokkit_server/notification/service/MerchantNotificationService.java b/src/main/java/com/example/Tokkit_server/notification/service/MerchantNotificationService.java new file mode 100644 index 0000000..19860e9 --- /dev/null +++ b/src/main/java/com/example/Tokkit_server/notification/service/MerchantNotificationService.java @@ -0,0 +1,175 @@ +package com.example.Tokkit_server.notification.service; + +import com.example.Tokkit_server.global.apiPayload.code.status.ErrorStatus; +import com.example.Tokkit_server.global.apiPayload.exception.GeneralException; +import com.example.Tokkit_server.merchant.entity.Merchant; +import com.example.Tokkit_server.merchant.repository.MerchantRepository; +import com.example.Tokkit_server.notification.dto.response.MerchantNotificationResponseDto; +import com.example.Tokkit_server.notification.entity.MerchantNotification; +import com.example.Tokkit_server.notification.entity.MerchantNotificationCategorySetting; +import com.example.Tokkit_server.notification.enums.NotificationCategory; +import com.example.Tokkit_server.notification.enums.NotificationTemplate; +import com.example.Tokkit_server.notification.repository.MerchantNotificationRepository; +import com.example.Tokkit_server.notification.repository.MerchantNotificationSettingRepository; +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; + +import java.io.IOException; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class MerchantNotificationService { + private final MerchantNotificationRepository notificationRepository; + private final MerchantNotificationSettingRepository settingRepository; + private final MerchantRepository merchantRepository; + private final SseEmitters sseEmitters; + private final EmailNotificationService emailNotificationService; + private final SseNotificationService sseNotificationService; + + private static final Long DEFAULT_TIMEOUT = 60L * 60 * 1000; + + @Transactional + public void sendMerchantNotification(Merchant merchant, NotificationTemplate template, Object... args) { + String title = template.getTitle(); + String content = String.format(template.getContentTemplate(), args); + + MerchantNotification notification = MerchantNotification.builder() + .merchant(merchant) + .category(template.getCategory()) + .title(title) + .content(content) + .build(); + + notificationRepository.save(notification); + } + + public SseEmitter subscribe(Long merchantId){ + SseEmitter emitter = new SseEmitter(DEFAULT_TIMEOUT); + sseEmitters.add(merchantId, emitter); + log.info("[SSE] 가맹점주 {} 구독 등록됨", merchantId); + + try { + emitter.send(SseEmitter.event() + .name("connect") + .data("SSE 연결이 완료되었습니다.")); + } catch (IOException e) { + emitter.completeWithError(e); + sseEmitters.remove(merchantId); + } + + merchantRepository.findById(merchantId).ifPresent(this::sendUnsentMerchantNotificationsAsync); + + ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + scheduler.scheduleAtFixedRate(() -> { + try { + emitter.send(SseEmitter.event() + .name("ping") + .data("keep-alive")); + } catch (Exception e) { + emitter.complete(); + } + }, 30, 30, TimeUnit.SECONDS); + + emitter.onCompletion(() -> { + sseEmitters.remove(merchantId); + scheduler.shutdown(); + }); + + emitter.onTimeout(() -> { + sseEmitters.remove(merchantId); + scheduler.shutdown(); + }); + + emitter.onError((e) -> { + sseEmitters.remove(merchantId); + scheduler.shutdown(); + }); + + return emitter; + } + + @Async + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void sendUnsentMerchantNotificationsAsync(Merchant merchant) { + sendUnsentMerchantNotifications(merchant); + } + + @Transactional + public void deleteNotification(Long merchantNotificationId, Merchant merchant) { + MerchantNotification merchantNotification = notificationRepository.findByIdAndMerchant(merchantNotificationId, merchant) + .orElseThrow(() -> new GeneralException(ErrorStatus.NOTIFICATION_NOT_FOUND)); + + if (!merchantNotification.getMerchant().getId().equals(merchant.getId())) { + throw new GeneralException(ErrorStatus._FORBIDDEN); + } + + merchantNotification.softDelete();; + notificationRepository.save(merchantNotification); + } + + @Transactional + public void sendUnsentMerchantNotifications(Merchant merchant) { + List unsentNotifications = notificationRepository.findByMerchantAndDeletedFalse(merchant); + + for (MerchantNotification notification : unsentNotifications) { + + if (!notification.isSentSse()) { + boolean success = sseNotificationService.sendSse(merchant.getId(), notification.getTitle(), notification.getContent()); + if (success) notification.markAsSentSse(); + } + if (!notification.isSentMail()) { + MerchantNotificationCategorySetting setting = settingRepository + .findByMerchantAndCategory(merchant, notification.getCategory()); + + if (setting != null && setting.isEnabled()) { + boolean success = emailNotificationService.sendEmail( + merchant.getEmail(), + notification.getTitle(), + notification.getContent() + ); + if (success) notification.markAsSentMail();; + } + } + } + + notificationRepository.saveAll(unsentNotifications); + } + + @Transactional(readOnly = true) + public List getAllNotifications(Merchant merchant) { + List enabledCategories = settingRepository.findByMerchantAndEnabledTrue(merchant) + .stream() + .map(MerchantNotificationCategorySetting::getCategory) + .collect(Collectors.toList()); + + return notificationRepository.findByMerchantAndCategoriesAndDeletedFalse(merchant, enabledCategories) + .stream() + .map(MerchantNotificationResponseDto::from) + .collect(Collectors.toList()); + } + + @Transactional(readOnly = true) + public List getMerchantNotificationsByCategory(Merchant merchant, NotificationCategory category) { + MerchantNotificationCategorySetting setting = settingRepository.findByMerchantAndCategory(merchant, category); + if (setting == null || !setting.isEnabled()) { + throw new GeneralException(ErrorStatus._FORBIDDEN); + } + + return notificationRepository.findByMerchantAndCategoryAndDeletedFalse(merchant, category) + .stream() + .map(MerchantNotificationResponseDto::from) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/example/Tokkit_server/notification/service/NotificationService.java b/src/main/java/com/example/Tokkit_server/notification/service/NotificationService.java index 67ff2fc..a0c6195 100644 --- a/src/main/java/com/example/Tokkit_server/notification/service/NotificationService.java +++ b/src/main/java/com/example/Tokkit_server/notification/service/NotificationService.java @@ -14,10 +14,7 @@ public interface NotificationService { void sendNotification(User user, NotificationTemplate template, Object... args); SseEmitter subscribe(Long userId); void deleteNotification(Long notificationId, User user); - void updateSetting(Long userId, List updateReqDtos); - void sendUnsentNotifications(User user); List getAllNotifications(User user); List getNotificationsByCategory(User user, NotificationCategory category); - List getSettings(Long userId); } 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 38032b8..c224ac9 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 @@ -144,27 +144,6 @@ public void deleteNotification(Long notificationId, User user) { notificationRepository.save(notification); } - @Transactional - public void updateSetting(Long userId, List updateReqDtos) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND)); - - List settings = notificationSettingRepository.findByUser(user); - - if (settings.isEmpty()) { - throw new GeneralException(ErrorStatus.NOTIFICATION_SETTING_NOT_FOUND); - } - - Map updateMap = updateReqDtos.stream() - .collect(Collectors.toMap(NotificationCategoryUpdateRequestDto::getCategory, NotificationCategoryUpdateRequestDto::isEnabled)); - - for (NotificationCategorySetting setting : settings) { - if (updateMap.containsKey(setting.getCategory())) { - setting.update(updateMap.get(setting.getCategory())); - } - } - } - @Transactional @Override public void sendUnsentNotifications(User user) { @@ -223,16 +202,4 @@ public List getNotificationsByCategory(User user, Notif .map(NotificationResponseDto::from) .collect(Collectors.toList()); } - - @Transactional(readOnly = true) - public List getSettings(Long userId) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND)); - - List settings = notificationSettingRepository.findByUser(user); - - return settings.stream() - .map(NotificationCategorySettingResponseDto::from) - .collect(Collectors.toList()); - } } From 8182f25413c7567a842adb66a4f6a2a8e81df999 Mon Sep 17 00:00:00 2001 From: noeyoes <> Date: Sat, 31 May 2025 12:39:43 +0900 Subject: [PATCH 18/34] =?UTF-8?q?feat:=20=EA=B0=80=EB=A7=B9=EC=A0=90?= =?UTF-8?q?=EC=A3=BC=20=EC=95=8C=EB=A6=BC=20=EC=84=A4=EC=A0=95=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...MerchantNotificationSettingController.java | 38 +++++++++++++ ...tNotificationCategoryUpdateRequestDto.java | 12 ++++ ...otificationCategorySettingResponseDto.java | 20 +++++++ .../MerchantNotificationCategorySetting.java | 33 +++++++++++ ...MerchantNotificationSettingRepository.java | 15 +++++ .../MerchantNotificationSettingService.java | 57 +++++++++++++++++++ 6 files changed, 175 insertions(+) create mode 100644 src/main/java/com/example/Tokkit_server/notification/controller/MerchantNotificationSettingController.java create mode 100644 src/main/java/com/example/Tokkit_server/notification/dto/request/MerchantNotificationCategoryUpdateRequestDto.java create mode 100644 src/main/java/com/example/Tokkit_server/notification/dto/response/MerchantNotificationCategorySettingResponseDto.java create mode 100644 src/main/java/com/example/Tokkit_server/notification/entity/MerchantNotificationCategorySetting.java create mode 100644 src/main/java/com/example/Tokkit_server/notification/repository/MerchantNotificationSettingRepository.java create mode 100644 src/main/java/com/example/Tokkit_server/notification/service/MerchantNotificationSettingService.java diff --git a/src/main/java/com/example/Tokkit_server/notification/controller/MerchantNotificationSettingController.java b/src/main/java/com/example/Tokkit_server/notification/controller/MerchantNotificationSettingController.java new file mode 100644 index 0000000..71e6073 --- /dev/null +++ b/src/main/java/com/example/Tokkit_server/notification/controller/MerchantNotificationSettingController.java @@ -0,0 +1,38 @@ +package com.example.Tokkit_server.notification.controller; + +import com.example.Tokkit_server.global.apiPayload.ApiResponse; +import com.example.Tokkit_server.global.apiPayload.code.status.SuccessStatus; +import com.example.Tokkit_server.merchant.auth.CustomMerchantDetails; +import com.example.Tokkit_server.notification.dto.request.MerchantNotificationCategoryUpdateRequestDto;import com.example.Tokkit_server.notification.dto.response.MerchantNotificationCategorySettingResponseDto; +import com.example.Tokkit_server.notification.service.MerchantNotificationSettingService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/merchants/notifications/setting") +@RequiredArgsConstructor +@Tag(name = "Merchant Notification Setting", description = "가맹점주 알림 설정 관련 API입니다.") +public class MerchantNotificationSettingController { + private final MerchantNotificationSettingService notificationSettingService; + + @GetMapping + @Operation(summary = "알림 설정 상태 조회", description = "유저의 알림 카테고리 설정 목록을 조회합니다.") + public ApiResponse> getSettings(@AuthenticationPrincipal CustomMerchantDetails merchantDetails) { + return ApiResponse.onSuccess(notificationSettingService.getSettings(merchantDetails.getId())); + } + + @PutMapping + @Operation(summary = "알림 설정 상태 수정", description = "유저의 알림 카테고리 설정을 수정합니다.") + public ApiResponse updateSetting( + @AuthenticationPrincipal CustomMerchantDetails merchantDetails, + @RequestBody List updateReqDtos + ) { + notificationSettingService.updateSetting(merchantDetails.getId(), updateReqDtos); + return ApiResponse.onSuccess(SuccessStatus._OK); + } +} diff --git a/src/main/java/com/example/Tokkit_server/notification/dto/request/MerchantNotificationCategoryUpdateRequestDto.java b/src/main/java/com/example/Tokkit_server/notification/dto/request/MerchantNotificationCategoryUpdateRequestDto.java new file mode 100644 index 0000000..7b9a298 --- /dev/null +++ b/src/main/java/com/example/Tokkit_server/notification/dto/request/MerchantNotificationCategoryUpdateRequestDto.java @@ -0,0 +1,12 @@ +package com.example.Tokkit_server.notification.dto.request; + +import com.example.Tokkit_server.merchant.entity.Merchant; +import com.example.Tokkit_server.notification.entity.MerchantNotificationCategorySetting; +import com.example.Tokkit_server.notification.enums.NotificationCategory; +import lombok.Getter; + +@Getter +public class MerchantNotificationCategoryUpdateRequestDto { + private NotificationCategory category; + private boolean enabled; +} diff --git a/src/main/java/com/example/Tokkit_server/notification/dto/response/MerchantNotificationCategorySettingResponseDto.java b/src/main/java/com/example/Tokkit_server/notification/dto/response/MerchantNotificationCategorySettingResponseDto.java new file mode 100644 index 0000000..56a167c --- /dev/null +++ b/src/main/java/com/example/Tokkit_server/notification/dto/response/MerchantNotificationCategorySettingResponseDto.java @@ -0,0 +1,20 @@ +package com.example.Tokkit_server.notification.dto.response; + +import com.example.Tokkit_server.notification.entity.MerchantNotificationCategorySetting; +import com.example.Tokkit_server.notification.enums.NotificationCategory; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class MerchantNotificationCategorySettingResponseDto { + private NotificationCategory category; + private boolean enabled; + + public static MerchantNotificationCategorySettingResponseDto from(MerchantNotificationCategorySetting categorySetting) { + return MerchantNotificationCategorySettingResponseDto.builder() + .category(categorySetting.getCategory()) + .enabled(categorySetting.isEnabled()) + .build(); + } +} diff --git a/src/main/java/com/example/Tokkit_server/notification/entity/MerchantNotificationCategorySetting.java b/src/main/java/com/example/Tokkit_server/notification/entity/MerchantNotificationCategorySetting.java new file mode 100644 index 0000000..f65c6f8 --- /dev/null +++ b/src/main/java/com/example/Tokkit_server/notification/entity/MerchantNotificationCategorySetting.java @@ -0,0 +1,33 @@ +package com.example.Tokkit_server.notification.entity; + +import com.example.Tokkit_server.global.entity.BaseTimeEntity; +import com.example.Tokkit_server.merchant.entity.Merchant; +import com.example.Tokkit_server.notification.enums.NotificationCategory; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class MerchantNotificationCategorySetting extends BaseTimeEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "merchant_id", nullable = false) + private Merchant merchant; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private NotificationCategory category; + + @Column(nullable = false) + private boolean enabled; + + public void update(boolean enabled) { + this.enabled = enabled; + } +} diff --git a/src/main/java/com/example/Tokkit_server/notification/repository/MerchantNotificationSettingRepository.java b/src/main/java/com/example/Tokkit_server/notification/repository/MerchantNotificationSettingRepository.java new file mode 100644 index 0000000..2515b6e --- /dev/null +++ b/src/main/java/com/example/Tokkit_server/notification/repository/MerchantNotificationSettingRepository.java @@ -0,0 +1,15 @@ +package com.example.Tokkit_server.notification.repository; + +import com.example.Tokkit_server.merchant.entity.Merchant; +import com.example.Tokkit_server.notification.entity.MerchantNotificationCategorySetting; +import com.example.Tokkit_server.notification.enums.NotificationCategory; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface MerchantNotificationSettingRepository extends JpaRepository { + List findByMerchant(Merchant merchant); + MerchantNotificationCategorySetting findByMerchantAndCategory(Merchant merchant, NotificationCategory category); + + List findByMerchantAndEnabledTrue(Merchant merchant); +} diff --git a/src/main/java/com/example/Tokkit_server/notification/service/MerchantNotificationSettingService.java b/src/main/java/com/example/Tokkit_server/notification/service/MerchantNotificationSettingService.java new file mode 100644 index 0000000..18c7f45 --- /dev/null +++ b/src/main/java/com/example/Tokkit_server/notification/service/MerchantNotificationSettingService.java @@ -0,0 +1,57 @@ +package com.example.Tokkit_server.notification.service; + +import com.example.Tokkit_server.global.apiPayload.code.status.ErrorStatus; +import com.example.Tokkit_server.global.apiPayload.exception.GeneralException; +import com.example.Tokkit_server.merchant.entity.Merchant; +import com.example.Tokkit_server.merchant.repository.MerchantRepository; +import com.example.Tokkit_server.notification.dto.request.MerchantNotificationCategoryUpdateRequestDto; +import com.example.Tokkit_server.notification.dto.request.NotificationCategoryUpdateRequestDto; +import com.example.Tokkit_server.notification.dto.response.MerchantNotificationCategorySettingResponseDto; +import com.example.Tokkit_server.notification.entity.MerchantNotificationCategorySetting; +import com.example.Tokkit_server.notification.enums.NotificationCategory; +import com.example.Tokkit_server.notification.repository.MerchantNotificationSettingRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class MerchantNotificationSettingService { + private final MerchantNotificationSettingRepository settingRepository; + private final MerchantRepository merchantRepository; + + public List getSettings(Long merchantId) { + Merchant merchant = merchantRepository.findById(merchantId) + .orElseThrow(() -> new GeneralException(ErrorStatus.MERCHANT_NOT_FOUND)); + + List settings = settingRepository.findByMerchant(merchant); + return settings.stream() + .map(MerchantNotificationCategorySettingResponseDto::from) + .collect(Collectors.toList()); + } + + @Transactional + public void updateSetting(Long merchantId, List updateRequest) { + Merchant merchant = merchantRepository.findById(merchantId) + .orElseThrow(() -> new GeneralException(ErrorStatus.MERCHANT_NOT_FOUND)); + + List settings = settingRepository.findByMerchant(merchant); + if (settings.isEmpty()) { + throw new GeneralException(ErrorStatus.NOTIFICATION_SETTING_NOT_FOUND); + } + + Map updateMap = updateRequest.stream() + .collect(Collectors.toMap(MerchantNotificationCategoryUpdateRequestDto::getCategory, MerchantNotificationCategoryUpdateRequestDto::isEnabled)); + + for (MerchantNotificationCategorySetting setting : settings) { + if (updateMap.containsKey(setting.getCategory())) { + setting.update(updateMap.get(setting.getCategory())); + } + } + } + +} From 8e22658ec36901f4ee96d4330c7f74e34ac77fdf Mon Sep 17 00:00:00 2001 From: noeyoes <> Date: Sat, 31 May 2025 13:18:28 +0900 Subject: [PATCH 19/34] =?UTF-8?q?feat:=20=EA=B0=80=EB=A7=B9=EC=A0=90?= =?UTF-8?q?=EC=A3=BC=20=EC=95=8C=EB=A6=BC=20=EC=83=9D=EC=84=B1=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notification/entity/MerchantNotification.java | 1 - .../command/MerchantWalletCommandService.java | 3 ++- .../service/query/MerchantWalletQueryService.java | 12 ++++++++++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/example/Tokkit_server/notification/entity/MerchantNotification.java b/src/main/java/com/example/Tokkit_server/notification/entity/MerchantNotification.java index 5460a51..08f5be0 100644 --- a/src/main/java/com/example/Tokkit_server/notification/entity/MerchantNotification.java +++ b/src/main/java/com/example/Tokkit_server/notification/entity/MerchantNotification.java @@ -3,7 +3,6 @@ import com.example.Tokkit_server.global.entity.BaseTimeEntity; import com.example.Tokkit_server.merchant.entity.Merchant; import com.example.Tokkit_server.notification.enums.NotificationCategory; -import com.example.Tokkit_server.user.entity.User; import jakarta.persistence.*; import lombok.*; diff --git a/src/main/java/com/example/Tokkit_server/wallet/service/command/MerchantWalletCommandService.java b/src/main/java/com/example/Tokkit_server/wallet/service/command/MerchantWalletCommandService.java index 17573ab..35c2f97 100644 --- a/src/main/java/com/example/Tokkit_server/wallet/service/command/MerchantWalletCommandService.java +++ b/src/main/java/com/example/Tokkit_server/wallet/service/command/MerchantWalletCommandService.java @@ -33,7 +33,7 @@ public class MerchantWalletCommandService { private final TransactionLogService transactionLogService; private void logAndSave(Wallet wallet, Long userId, Long merchantId, - TransactionType type, TransactionStatus status, Long amount, String description) { + TransactionType type, TransactionStatus status, Long amount, String description, String displayDescription) { transactionLogService.logAndSave( Transaction.builder() .wallet(wallet) @@ -42,6 +42,7 @@ private void logAndSave(Wallet wallet, Long userId, Long merchantId, .amount(amount) .txHash(null) .description(description) + .displayDescription(displayDescription) .traceId(MDC.get("traceId")) .build(), userId, diff --git a/src/main/java/com/example/Tokkit_server/wallet/service/query/MerchantWalletQueryService.java b/src/main/java/com/example/Tokkit_server/wallet/service/query/MerchantWalletQueryService.java index 0ed5510..e5094a6 100644 --- a/src/main/java/com/example/Tokkit_server/wallet/service/query/MerchantWalletQueryService.java +++ b/src/main/java/com/example/Tokkit_server/wallet/service/query/MerchantWalletQueryService.java @@ -4,6 +4,8 @@ import com.example.Tokkit_server.global.apiPayload.exception.GeneralException; import com.example.Tokkit_server.merchant.entity.Merchant; import com.example.Tokkit_server.merchant.repository.MerchantRepository; +import com.example.Tokkit_server.notification.enums.NotificationTemplate; +import com.example.Tokkit_server.notification.service.MerchantNotificationService; import com.example.Tokkit_server.transaction.entity.Transaction; import com.example.Tokkit_server.transaction.enums.TransactionStatus; import com.example.Tokkit_server.transaction.enums.TransactionType; @@ -34,6 +36,7 @@ public class MerchantWalletQueryService { private final PasswordEncoder passwordEncoder; private final TransactionLogService transactionLogService; private final TokkitTokenService tokkitTokenService; + private final MerchantNotificationService merchantNotificationService; private void logAndSave(Wallet wallet, Long userId, Long merchantId, TransactionType type, TransactionStatus status, Long amount, String description, String displayDescription) { @@ -86,6 +89,13 @@ public void convertTokenToDeposit(Long merchantId, TokenToDepositRequest request "토큰 ➝ 예금 변환", "토큰 ➝ 예금" ); + + // 가맹점주 토큰 ➝ 예금 변환 알림 생성 + merchantNotificationService.sendMerchantNotification( + merchant, + NotificationTemplate.DEPOSIT_CONVERTED, + request.getAmount() + ); } /** @@ -138,6 +148,4 @@ public BigInteger getOnChainTokenBalance(Long merchantId) { throw new GeneralException(ErrorStatus.BALANCE_VERIFICATION_FAILED); } } - - } From f3ae5666b43406333841df8e8ecd7116153dabf0 Mon Sep 17 00:00:00 2001 From: noeyoes <> Date: Sat, 31 May 2025 13:18:40 +0900 Subject: [PATCH 20/34] =?UTF-8?q?feat:=20=EA=B0=80=EB=A7=B9=EC=A0=90?= =?UTF-8?q?=EC=A3=BC=20=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20=EC=8B=9C=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EC=84=A4=EC=A0=95=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Tokkit_server/merchant/service/MerchantService.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/com/example/Tokkit_server/merchant/service/MerchantService.java b/src/main/java/com/example/Tokkit_server/merchant/service/MerchantService.java index d565c84..6741ea0 100644 --- a/src/main/java/com/example/Tokkit_server/merchant/service/MerchantService.java +++ b/src/main/java/com/example/Tokkit_server/merchant/service/MerchantService.java @@ -13,6 +13,7 @@ import com.example.Tokkit_server.merchant.repository.MerchantRepository; import com.example.Tokkit_server.notification.entity.MerchantNotificationCategorySetting; import com.example.Tokkit_server.notification.enums.NotificationCategory; +import com.example.Tokkit_server.notification.repository.MerchantNotificationSettingRepository; import com.example.Tokkit_server.ocr.service.KakaoAddressSearchService; import com.example.Tokkit_server.ocr.utils.KakaoGeoResult; import com.example.Tokkit_server.region.entity.Region; @@ -44,6 +45,7 @@ public class MerchantService { private final WalletCommandService walletCommandService; private final PasswordEncoder passwordEncoder; private final KakaoAddressSearchService kakaoAddressSearchService; + private final MerchantNotificationSettingRepository notificationSettingRepository; private final GeometryFactory geometryFactory = new GeometryFactory(); // 회원가입 @@ -113,6 +115,7 @@ public MerchantRegisterResponseDto createMerchant(CreateMerchantRequestDto reque .category(category) .enabled(true) .build(); + notificationSettingRepository.save(setting); } return MerchantRegisterResponseDto.from(merchant); From 99e0511e55419ebeaa81731fda1a5ee0818de6a2 Mon Sep 17 00:00:00 2001 From: YoungjaeRo Date: Mon, 2 Jun 2025 11:35:57 +0900 Subject: [PATCH 21/34] =?UTF-8?q?refactor=20:=20=EC=A0=84=ED=99=98=20?= =?UTF-8?q?=EC=98=88=EC=95=BD=20=ED=98=84=ED=99=A9=EC=97=90=20=EB=B6=84?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../wallet/dto/response/AutoConvertSettingResponse.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/example/Tokkit_server/wallet/dto/response/AutoConvertSettingResponse.java b/src/main/java/com/example/Tokkit_server/wallet/dto/response/AutoConvertSettingResponse.java index b2ab8a0..72d6566 100644 --- a/src/main/java/com/example/Tokkit_server/wallet/dto/response/AutoConvertSettingResponse.java +++ b/src/main/java/com/example/Tokkit_server/wallet/dto/response/AutoConvertSettingResponse.java @@ -11,5 +11,6 @@ public class AutoConvertSettingResponse { private boolean enabled; private int dayOfMonth; private int hour; + private int minute; private long amount; } \ No newline at end of file From c332e8e022e4235b385d205367b1a9bb5d4e3ecf Mon Sep 17 00:00:00 2001 From: YoungjaeRo Date: Mon, 2 Jun 2025 11:36:32 +0900 Subject: [PATCH 22/34] =?UTF-8?q?refactor=20:=20=EC=A7=80=EA=B0=91=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A4=EC=8B=9C,=20=EC=9E=90=EB=8F=99=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EC=83=81=ED=83=9C=20false=EB=A1=9C=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../wallet/service/command/WalletCommandService.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/com/example/Tokkit_server/wallet/service/command/WalletCommandService.java b/src/main/java/com/example/Tokkit_server/wallet/service/command/WalletCommandService.java index c8f94c8..3758874 100644 --- a/src/main/java/com/example/Tokkit_server/wallet/service/command/WalletCommandService.java +++ b/src/main/java/com/example/Tokkit_server/wallet/service/command/WalletCommandService.java @@ -126,6 +126,7 @@ public Wallet createInitialWalletForUser(Long userId) { .walletType(WalletType.USER) .accountNumber(AccountGenerator.generateAccountNumber()) .walletAddress(walletAddress) + .autoConvertEnabled(false) .build(); return walletRepository.save(wallet); @@ -156,6 +157,7 @@ public Wallet createInitialWalletForMerchant(Long merchantId) { .walletType(WalletType.MERCHANT) .accountNumber(AccountGenerator.generateAccountNumber()) .walletAddress(walletAddress) + .autoConvertEnabled(false) .build(); return walletRepository.save(wallet); From 171298d028a37e33dbc3aa56f294f94da272e367 Mon Sep 17 00:00:00 2001 From: YoungjaeRo Date: Mon, 2 Jun 2025 11:37:04 +0900 Subject: [PATCH 23/34] =?UTF-8?q?feat=20:=20=EC=9E=90=EB=8F=99=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EC=84=A4=EC=A0=95=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../wallet/controller/WalletController.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/main/java/com/example/Tokkit_server/wallet/controller/WalletController.java b/src/main/java/com/example/Tokkit_server/wallet/controller/WalletController.java index fe12372..1482f56 100644 --- a/src/main/java/com/example/Tokkit_server/wallet/controller/WalletController.java +++ b/src/main/java/com/example/Tokkit_server/wallet/controller/WalletController.java @@ -126,4 +126,13 @@ public ApiResponse updateAutoConvertSetting( return ApiResponse.onSuccess(null); } + + @GetMapping("/auto-convert") + @Operation(summary = "자동 전환 설정 조회", description = "자동 전환 설정 상태를 반환합니다.") + public ApiResponse getAutoConvertSetting( + @AuthenticationPrincipal CustomUserDetails userDetails + ) { + return ApiResponse.onSuccess(queryService.getAutoConvertSetting(userDetails.getId())); + } + } \ No newline at end of file From c8215ab888ce11e9a8f1aa40038c491e344e6c0e Mon Sep 17 00:00:00 2001 From: YoungjaeRo Date: Mon, 2 Jun 2025 11:37:22 +0900 Subject: [PATCH 24/34] =?UTF-8?q?feat=20:=20=EC=9E=90=EB=8F=99=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EC=84=A4=EC=A0=95=20=EC=A1=B0=ED=9A=8C=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EB=A1=9C=EC=A7=81=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/query/WalletQueryService.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/main/java/com/example/Tokkit_server/wallet/service/query/WalletQueryService.java b/src/main/java/com/example/Tokkit_server/wallet/service/query/WalletQueryService.java index 5865e6e..fcbccef 100644 --- a/src/main/java/com/example/Tokkit_server/wallet/service/query/WalletQueryService.java +++ b/src/main/java/com/example/Tokkit_server/wallet/service/query/WalletQueryService.java @@ -14,6 +14,7 @@ import com.example.Tokkit_server.wallet.dto.request.AutoConvertSettingRequest; import com.example.Tokkit_server.wallet.dto.request.DepositToTokenRequest; import com.example.Tokkit_server.wallet.dto.request.TokenToDepositRequest; +import com.example.Tokkit_server.wallet.dto.response.AutoConvertSettingResponse; import com.example.Tokkit_server.wallet.dto.response.TransactionDetailResponse; import com.example.Tokkit_server.wallet.dto.response.TransactionHistoryResponse; import com.example.Tokkit_server.wallet.entity.Wallet; @@ -242,5 +243,24 @@ public void updateAutoConvertSetting(Long userId, AutoConvertSettingRequest requ request.getMinute(), request.getAmount() ); + + } + + /** + * 변환 예약 상태 확인 + */ + public AutoConvertSettingResponse getAutoConvertSetting(Long userId) { + Wallet wallet = walletRepository.findByUserId(userId) + .orElseThrow(() -> new GeneralException(ErrorStatus.USER_WALLET_NOT_FOUND)); + + return new AutoConvertSettingResponse( + wallet.isAutoConvertEnabled(), + wallet.getAutoConvertDayOfMonth(), + wallet.getAutoConvertHour(), + wallet.getAutoConvertMinute(), + wallet.getAutoConvertAmount() + ); } + + } \ No newline at end of file From ac33c3c4e237ac919f0cff17e4245fb6cc88c468 Mon Sep 17 00:00:00 2001 From: YoungjaeRo Date: Mon, 2 Jun 2025 11:38:00 +0900 Subject: [PATCH 25/34] =?UTF-8?q?chore=20:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EC=9D=98=EC=A1=B4=EC=84=B1=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 ++ 1 file changed, 2 insertions(+) diff --git a/build.gradle b/build.gradle index 1b1301f..5a369e9 100644 --- a/build.gradle +++ b/build.gradle @@ -50,6 +50,8 @@ dependencies { // Spring Security implementation 'org.springframework.boot:spring-boot-starter-security' + testImplementation 'org.springframework.security:spring-security-test' + // jwt implementation 'io.jsonwebtoken:jjwt-api:0.12.3' From ed73933a2df2c99651d69e1da0e83dc0a999f90b Mon Sep 17 00:00:00 2001 From: noeyoes <> Date: Mon, 2 Jun 2025 11:44:04 +0900 Subject: [PATCH 26/34] chore: git conflict --- .../Tokkit_server/wallet/repository/WalletRepository.java | 8 ++------ .../wallet/scheduler/AutoTokenConvertScheduler.java | 2 +- .../service/command/MerchantWalletCommandService.java | 2 +- .../wallet/service/command/WalletCommandService.java | 8 ++++---- 4 files changed, 8 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/example/Tokkit_server/wallet/repository/WalletRepository.java b/src/main/java/com/example/Tokkit_server/wallet/repository/WalletRepository.java index f73b2f4..92d9b40 100644 --- a/src/main/java/com/example/Tokkit_server/wallet/repository/WalletRepository.java +++ b/src/main/java/com/example/Tokkit_server/wallet/repository/WalletRepository.java @@ -10,17 +10,13 @@ import javax.swing.text.html.Option; public interface WalletRepository extends JpaRepository { - Optional findByUserId(Long userId); Optional findByUser_Id(Long userId); - Optional findByMerchantId(Long merchantId); - Optional findByMerchant_Id(Long merchantId); - boolean existsByUserId(Long userId); - - boolean existsByMerchantId(Long merchantId); + boolean existsByUser_Id(Long userId); List findByAutoConvertEnabledTrue(); + boolean existsByMerchant_Id(Long merchantId); } \ No newline at end of file diff --git a/src/main/java/com/example/Tokkit_server/wallet/scheduler/AutoTokenConvertScheduler.java b/src/main/java/com/example/Tokkit_server/wallet/scheduler/AutoTokenConvertScheduler.java index b4f8c71..1543f84 100644 --- a/src/main/java/com/example/Tokkit_server/wallet/scheduler/AutoTokenConvertScheduler.java +++ b/src/main/java/com/example/Tokkit_server/wallet/scheduler/AutoTokenConvertScheduler.java @@ -110,7 +110,7 @@ public void runMonthlyAutoConversion() { TransactionStatus.SUCCESS, amount, "정기 자동 예금 → 토큰 전환", - "자동 충전 실행", + "자동 충전", receipt.getTransactionHash() ); diff --git a/src/main/java/com/example/Tokkit_server/wallet/service/command/MerchantWalletCommandService.java b/src/main/java/com/example/Tokkit_server/wallet/service/command/MerchantWalletCommandService.java index 35c2f97..280173c 100644 --- a/src/main/java/com/example/Tokkit_server/wallet/service/command/MerchantWalletCommandService.java +++ b/src/main/java/com/example/Tokkit_server/wallet/service/command/MerchantWalletCommandService.java @@ -65,7 +65,7 @@ public MerchantWalletBalanceResponse getWalletBalance(Long merchantId) { * 일일 매출 조회 */ public Long getDailyIncome(Long merchantId) { - Optional wallet = walletRepository.findByMerchantId(merchantId); + Optional wallet = walletRepository.findByMerchant_Id(merchantId); LocalDateTime startOfDay = LocalDate.now().atStartOfDay(); LocalDateTime endOfDay = LocalDate.now().atTime(LocalTime.MAX); diff --git a/src/main/java/com/example/Tokkit_server/wallet/service/command/WalletCommandService.java b/src/main/java/com/example/Tokkit_server/wallet/service/command/WalletCommandService.java index c8f94c8..216f3f5 100644 --- a/src/main/java/com/example/Tokkit_server/wallet/service/command/WalletCommandService.java +++ b/src/main/java/com/example/Tokkit_server/wallet/service/command/WalletCommandService.java @@ -107,8 +107,8 @@ private void logAndSave(Wallet wallet, Long userId, Long merchantId, // 유저 - 전자 지갑 생성 @Transactional public Wallet createInitialWalletForUser(Long userId) { - if (walletRepository.existsByUserId(userId)) { - return walletRepository.findByUserId(userId) + if (walletRepository.existsByUser_Id(userId)) { + return walletRepository.findByUser_Id(userId) .orElseThrow(() -> new GeneralException(ErrorStatus.USER_WALLET_NOT_FOUND)); } @@ -137,8 +137,8 @@ public Wallet createInitialWalletForUser(Long userId) { // 가맹점주 - 전자 지갑 생성 @Transactional public Wallet createInitialWalletForMerchant(Long merchantId) { - if (walletRepository.existsByMerchantId(merchantId)) { - return walletRepository.findByMerchantId(merchantId) + if (walletRepository.existsByMerchant_Id(merchantId)) { + return walletRepository.findByMerchant_Id(merchantId) .orElseThrow(() -> new GeneralException(ErrorStatus.MERCHANT_WALLET_NOT_FOUND)); } From e4ca38c8db43c1fc099ef334f7d9996d9559e1e9 Mon Sep 17 00:00:00 2001 From: noeyoes <> Date: Mon, 2 Jun 2025 15:32:10 +0900 Subject: [PATCH 27/34] =?UTF-8?q?feat:=20=EA=B0=80=EB=A7=B9=EC=A0=90?= =?UTF-8?q?=EC=A3=BC=20=EC=95=8C=EB=A6=BC=20=EC=83=9D=EC=84=B1=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../wallet/repository/WalletRepository.java | 8 ++++-- .../service/command/WalletCommandService.java | 25 ++++++++++++++++--- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/example/Tokkit_server/wallet/repository/WalletRepository.java b/src/main/java/com/example/Tokkit_server/wallet/repository/WalletRepository.java index 92d9b40..f73b2f4 100644 --- a/src/main/java/com/example/Tokkit_server/wallet/repository/WalletRepository.java +++ b/src/main/java/com/example/Tokkit_server/wallet/repository/WalletRepository.java @@ -10,13 +10,17 @@ import javax.swing.text.html.Option; public interface WalletRepository extends JpaRepository { + Optional findByUserId(Long userId); Optional findByUser_Id(Long userId); + Optional findByMerchantId(Long merchantId); + Optional findByMerchant_Id(Long merchantId); - boolean existsByUser_Id(Long userId); + boolean existsByUserId(Long userId); + + boolean existsByMerchantId(Long merchantId); List findByAutoConvertEnabledTrue(); - boolean existsByMerchant_Id(Long merchantId); } \ No newline at end of file diff --git a/src/main/java/com/example/Tokkit_server/wallet/service/command/WalletCommandService.java b/src/main/java/com/example/Tokkit_server/wallet/service/command/WalletCommandService.java index b310e7a..a75c9d8 100644 --- a/src/main/java/com/example/Tokkit_server/wallet/service/command/WalletCommandService.java +++ b/src/main/java/com/example/Tokkit_server/wallet/service/command/WalletCommandService.java @@ -5,6 +5,7 @@ import com.example.Tokkit_server.merchant.entity.Merchant; import com.example.Tokkit_server.merchant.repository.MerchantRepository; import com.example.Tokkit_server.notification.enums.NotificationTemplate; +import com.example.Tokkit_server.notification.service.MerchantNotificationService; import com.example.Tokkit_server.notification.service.NotificationService; import com.example.Tokkit_server.store.entity.Store; import com.example.Tokkit_server.store.repository.StoreRepository; @@ -61,6 +62,7 @@ public class WalletCommandService { private final TokkitTokenService tokkitTokenService; private final StoreRepository storeRepository; private final NotificationService notificationService; + private final MerchantNotificationService merchantNotificationService; /** * txHash 없는 기본형 @@ -107,7 +109,7 @@ private void logAndSave(Wallet wallet, Long userId, Long merchantId, // 유저 - 전자 지갑 생성 @Transactional public Wallet createInitialWalletForUser(Long userId) { - if (walletRepository.existsByUser_Id(userId)) { + if (walletRepository.existsByUserId(userId)) { return walletRepository.findByUser_Id(userId) .orElseThrow(() -> new GeneralException(ErrorStatus.USER_WALLET_NOT_FOUND)); } @@ -138,7 +140,7 @@ public Wallet createInitialWalletForUser(Long userId) { // 가맹점주 - 전자 지갑 생성 @Transactional public Wallet createInitialWalletForMerchant(Long merchantId) { - if (walletRepository.existsByMerchant_Id(merchantId)) { + if (walletRepository.existsByMerchantId(merchantId)) { return walletRepository.findByMerchant_Id(merchantId) .orElseThrow(() -> new GeneralException(ErrorStatus.MERCHANT_WALLET_NOT_FOUND)); } @@ -389,7 +391,16 @@ public VoucherPaymentResponse payWithVoucher(Long userId,VoucherPaymentRequest r request.getAmount() ); - // TODO: 가맹점주 바우처 정산 알림 생성 + // 가맹점주 바우처 정산 알림 생성 + Merchant merchant = merchantRepository.findById(request.getMerchantId()) + .orElseThrow(() -> new GeneralException(ErrorStatus.MERCHANT_NOT_FOUND)); + merchantNotificationService.sendMerchantNotification( + merchant, + NotificationTemplate.MERCHANT_VOUCHER_SETTLED, + user.getName(), + voucher.getName(), + request.getAmount() + ); // 12. 응답 반환 return VoucherPaymentResponse.builder() @@ -494,7 +505,13 @@ public DirectPaymentResponse payDirectlyWithToken(Long userId,DirectPaymentReque request.getAmount() ); - // TODO: 가맹점주 토큰 정산 알림 생성 + // 가맹점주 토큰 정산 알림 생성 + merchantNotificationService.sendMerchantNotification( + merchant, + NotificationTemplate.MERCHANT_TOKEN_SETTLED, + user.getName(), + request.getAmount() + ); // 10. 응답 반환 return DirectPaymentResponse.builder() From 452ec777e6443c2d3ed6bc200ec3c5d0a91f6fb6 Mon Sep 17 00:00:00 2001 From: noeyoes <> Date: Mon, 2 Jun 2025 16:54:17 +0900 Subject: [PATCH 28/34] =?UTF-8?q?feat:=20transaction=20description=20forma?= =?UTF-8?q?tter=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../utils/TransactionDisplayFormatter.java | 20 +++++++++++++++++++ .../service/command/WalletCommandService.java | 10 +++++----- 2 files changed, 25 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/example/Tokkit_server/transaction/utils/TransactionDisplayFormatter.java diff --git a/src/main/java/com/example/Tokkit_server/transaction/utils/TransactionDisplayFormatter.java b/src/main/java/com/example/Tokkit_server/transaction/utils/TransactionDisplayFormatter.java new file mode 100644 index 0000000..e9245fe --- /dev/null +++ b/src/main/java/com/example/Tokkit_server/transaction/utils/TransactionDisplayFormatter.java @@ -0,0 +1,20 @@ +package com.example.Tokkit_server.transaction.utils; + +public class TransactionDisplayFormatter { + + public static String userTokenPayment(String storeName) { + return String.format("[토큰 결제] %s", storeName); + } + + public static String userVoucherPayment(String voucherName, String storeName, String userName) { + return String.format("[바우처 결제] %s, %s에서 '%s' 사용", userName, storeName, voucherName); + } + + public static String merchantTokenSettlement(String userName) { + return String.format("[토큰 정산] %s", userName); + } + + public static String merchantVoucherSettlement(String voucherName, String userName) { + return String.format("[바우처 정산] %s, '%s' 바우처 정산 완료", userName, voucherName); + } +} diff --git a/src/main/java/com/example/Tokkit_server/wallet/service/command/WalletCommandService.java b/src/main/java/com/example/Tokkit_server/wallet/service/command/WalletCommandService.java index a75c9d8..401744f 100644 --- a/src/main/java/com/example/Tokkit_server/wallet/service/command/WalletCommandService.java +++ b/src/main/java/com/example/Tokkit_server/wallet/service/command/WalletCommandService.java @@ -14,6 +14,7 @@ import com.example.Tokkit_server.transaction.enums.TransactionType; import com.example.Tokkit_server.transaction.repository.TransactionRepository; import com.example.Tokkit_server.transaction.service.query.TransactionLogService; +import com.example.Tokkit_server.transaction.utils.TransactionDisplayFormatter; import com.example.Tokkit_server.user.entity.User; import com.example.Tokkit_server.user.repository.UserRepository; import com.example.Tokkit_server.voucher.entity.Voucher; @@ -346,8 +347,7 @@ public VoucherPaymentResponse payWithVoucher(Long userId,VoucherPaymentRequest r Store store = storeRepository.findById(request.getStoreId()) .orElseThrow(() -> new GeneralException(ErrorStatus.STORE_NOT_FOUND)); - String userDisplayDescription = store.getStoreName(); - + String userDisplayDescription = TransactionDisplayFormatter.userVoucherPayment(user.getName(), store.getStoreName(), voucher.getName()); // 9. 사용자 거래 기록 생성 logAndSave(ownership.getWallet(), user.getId(), null, TransactionType.PURCHASE, TransactionStatus.SUCCESS, @@ -376,7 +376,7 @@ public VoucherPaymentResponse payWithVoucher(Long userId,VoucherPaymentRequest r // Merchant Description 생성 String merchantLogDescription = "바우처 정산 수령 - User ID: " + user.getId(); - String merchantDisplayDescription = user.getName(); + String merchantDisplayDescription = TransactionDisplayFormatter.merchantVoucherSettlement(voucher.getName(), user.getName()); // 11. 가맹점주 거래 기록 저장 logAndSave(merchantWallet, null, request.getMerchantId(), TransactionType.RECEIVE, TransactionStatus.SUCCESS, @@ -483,7 +483,7 @@ public DirectPaymentResponse payDirectlyWithToken(Long userId,DirectPaymentReque Store store = storeRepository.findByMerchantId(toMerchant.getId()) .orElseThrow(() -> new GeneralException(ErrorStatus.STORE_NOT_FOUND)); - String userDisplayDescription = store.getStoreName(); + String userDisplayDescription = TransactionDisplayFormatter.userTokenPayment(store.getStoreName()); // 8. 유저 거래 내역 저장 logAndSave(userWallet, user.getId(), null, TransactionType.PURCHASE, TransactionStatus.SUCCESS, @@ -491,7 +491,7 @@ public DirectPaymentResponse payDirectlyWithToken(Long userId,DirectPaymentReque // Merchant Description 생성 String merchantLogDescription = "토큰 직접 결제 수령 - User ID: " + user.getId(); - String merchantDisplayDescription = user.getName(); + String merchantDisplayDescription = TransactionDisplayFormatter.merchantTokenSettlement(user.getName()); // 9. 가맹점주 거래 기록 저장 logAndSave(merchantWallet, null, merchant.getId(), TransactionType.RECEIVE, TransactionStatus.SUCCESS, From f0f5ea89234de8903598a3cb984ec7f712a5f082 Mon Sep 17 00:00:00 2001 From: noeyoes <> Date: Mon, 2 Jun 2025 17:36:26 +0900 Subject: [PATCH 29/34] =?UTF-8?q?feat:=20=EA=B0=80=EB=A7=B9=EC=A0=90?= =?UTF-8?q?=EC=A3=BC=20=EB=B8=94=EB=A1=9D=EC=B2=B4=EC=9D=B8=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=EB=B3=B4=EA=B8=B0=20=EC=BD=94=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../wallet/controller/MerchantWalletController.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/main/java/com/example/Tokkit_server/wallet/controller/MerchantWalletController.java b/src/main/java/com/example/Tokkit_server/wallet/controller/MerchantWalletController.java index dd8c169..1c4d858 100644 --- a/src/main/java/com/example/Tokkit_server/wallet/controller/MerchantWalletController.java +++ b/src/main/java/com/example/Tokkit_server/wallet/controller/MerchantWalletController.java @@ -5,6 +5,7 @@ import com.example.Tokkit_server.wallet.dto.request.TokenToDepositRequest; import com.example.Tokkit_server.wallet.dto.response.*; import com.example.Tokkit_server.wallet.service.command.MerchantWalletCommandService; +import com.example.Tokkit_server.wallet.service.query.BlockchainQueryService; import com.example.Tokkit_server.wallet.service.query.MerchantWalletQueryService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -23,6 +24,7 @@ public class MerchantWalletController { private final MerchantWalletCommandService commandService; private final MerchantWalletQueryService queryService; + private final BlockchainQueryService blockchainQueryService; @GetMapping("/balance") @Operation(summary = "잔액 조회", description = "사용자 ID로 잔액 조회") @@ -70,6 +72,12 @@ public ApiResponse getTransactionDetail(@PathVariable return ApiResponse.onSuccess(queryService.getTransactionDetail(id)); } + @GetMapping("/tx/{txHash}") + @Operation(summary = "txHash 상세 조회", description = "특정 거래에 대해 블록체인 상세 정보를 조회합니다.") + public ApiResponse getTxDetail(@PathVariable String txHash) { + return ApiResponse.onSuccess(blockchainQueryService.getTxHashDetail(txHash)); + } + @GetMapping("/onchain-token-balance") @Operation(summary = "온체인 토큰 잔액 조회", description = "가맹점주의 지갑 주소 기준으로 스마트 컨트랙트 상 실제 토큰 잔액을 조회합니다.") public ApiResponse getOnChainTokenBalance(@AuthenticationPrincipal CustomMerchantDetails merchantDetails) { From c0c79d001bf3b5a8168ee8db85f634f9e47dc5ef Mon Sep 17 00:00:00 2001 From: noeyoes <> Date: Tue, 3 Jun 2025 14:39:35 +0900 Subject: [PATCH 30/34] =?UTF-8?q?feat:=20=EC=9E=90=EB=8F=99=20=EC=A0=84?= =?UTF-8?q?=ED=99=98=20=EC=95=8C=EB=A6=BC=20=EC=83=9D=EC=84=B1=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notification/enums/NotificationTemplate.java | 1 + .../wallet/scheduler/AutoTokenConvertScheduler.java | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/src/main/java/com/example/Tokkit_server/notification/enums/NotificationTemplate.java b/src/main/java/com/example/Tokkit_server/notification/enums/NotificationTemplate.java index 43d4c92..3295fff 100644 --- a/src/main/java/com/example/Tokkit_server/notification/enums/NotificationTemplate.java +++ b/src/main/java/com/example/Tokkit_server/notification/enums/NotificationTemplate.java @@ -10,6 +10,7 @@ public enum NotificationTemplate { // TOKEN 알림 TOKEN_CONVERTED(NotificationCategory.TOKEN, "토큰 전환 완료", "예금 %d원이 토큰으로 전환되었습니다.", false, true), + TOKEN_AUTO_CONVERTED(NotificationCategory.TOKEN, "토큰 자동 전환 완료", "예금 %d원이 토큰으로 전환되었습니다.", false, true), DEPOSIT_CONVERTED(NotificationCategory.TOKEN, "예금 전환 완료", "토큰 %d TKT가 예금으로 전환되었습니다.", false, true), /** * USER 알림 diff --git a/src/main/java/com/example/Tokkit_server/wallet/scheduler/AutoTokenConvertScheduler.java b/src/main/java/com/example/Tokkit_server/wallet/scheduler/AutoTokenConvertScheduler.java index 1543f84..5f51091 100644 --- a/src/main/java/com/example/Tokkit_server/wallet/scheduler/AutoTokenConvertScheduler.java +++ b/src/main/java/com/example/Tokkit_server/wallet/scheduler/AutoTokenConvertScheduler.java @@ -7,6 +7,9 @@ import java.util.Objects; import java.util.UUID; +import com.example.Tokkit_server.notification.enums.NotificationTemplate; +import com.example.Tokkit_server.notification.service.NotificationService; +import com.example.Tokkit_server.user.entity.User; import org.slf4j.MDC; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.scheduling.annotation.Scheduled; @@ -33,6 +36,7 @@ public class AutoTokenConvertScheduler { private final TokkitTokenService tokkitTokenService; private final TransactionLogService transactionLogService; private final RedisTemplate redisTemplate; + private final NotificationService notificationService; /** * txHash 있는 확장형 @@ -117,6 +121,13 @@ public void runMonthlyAutoConversion() { log.info("자동 전환 완료: userId={}, amount={}, txHash={}", wallet.getUser().getId(), amount, receipt.getTransactionHash()); + User user = wallet.getUser(); + notificationService.sendNotification( + user, + NotificationTemplate.TOKEN_AUTO_CONVERTED, + amount + ); + } catch (Exception e) { log.error("자동 전환 실패: userId={}", wallet.getUser().getId(), e); } From 2d067b8c6be10db885c091fc3a1dc7b65b4955a6 Mon Sep 17 00:00:00 2001 From: noeyoes <> Date: Tue, 3 Jun 2025 14:40:23 +0900 Subject: [PATCH 31/34] =?UTF-8?q?refactor:=20=EC=9D=B4=EB=A9=94=EC=9D=BC?= =?UTF-8?q?=20=EC=A0=84=EC=86=A1=20=EC=95=8C=EB=A6=BC=20=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/EmailNotificationService.java | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/example/Tokkit_server/notification/service/EmailNotificationService.java b/src/main/java/com/example/Tokkit_server/notification/service/EmailNotificationService.java index cc44f94..9e0c8c1 100644 --- a/src/main/java/com/example/Tokkit_server/notification/service/EmailNotificationService.java +++ b/src/main/java/com/example/Tokkit_server/notification/service/EmailNotificationService.java @@ -1,8 +1,9 @@ package com.example.Tokkit_server.notification.service; +import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeMessage; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.stereotype.Service; @@ -15,10 +16,22 @@ public class EmailNotificationService { public boolean sendEmail(String to, String subject, String text) { try { - SimpleMailMessage message = new SimpleMailMessage(); - message.setTo(to); + MimeMessage message = mailSender.createMimeMessage(); + + message.addRecipients(MimeMessage.RecipientType.TO, to); message.setSubject(subject); - message.setText(text); + + // HTML 형식으로 메일 본문 구성 + String html = """ +
+

Tokkit 알림

+

%s

+

본 메일은 Tokkit 서비스에 의해 발송되었습니다.

+
+ """.formatted(text); + + message.setText(html, "utf-8", "html"); + message.setFrom(new InternetAddress("Tokkit", "토킷")); mailSender.send(message); return true; @@ -27,4 +40,4 @@ public boolean sendEmail(String to, String subject, String text) { return false; } } -} \ No newline at end of file +} From 6b23e18139723c2f2c932aee38c6670ac466d0d0 Mon Sep 17 00:00:00 2001 From: noeyoes <> Date: Wed, 4 Jun 2025 14:04:34 +0900 Subject: [PATCH 32/34] =?UTF-8?q?refactor:=20=EC=98=88=EA=B8=88=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EC=9E=90=EB=8F=99=20=EC=A0=84=ED=99=98=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../enums/NotificationTemplate.java | 2 +- .../service/NotificationServiceImpl.java | 75 ++++++++++++++++--- .../service/SseNotificationService.java | 36 +++++---- .../Tokkit_server/user/utils/SseEmitters.java | 22 +++++- .../scheduler/AutoTokenConvertScheduler.java | 2 + 5 files changed, 108 insertions(+), 29 deletions(-) diff --git a/src/main/java/com/example/Tokkit_server/notification/enums/NotificationTemplate.java b/src/main/java/com/example/Tokkit_server/notification/enums/NotificationTemplate.java index 3295fff..524e4af 100644 --- a/src/main/java/com/example/Tokkit_server/notification/enums/NotificationTemplate.java +++ b/src/main/java/com/example/Tokkit_server/notification/enums/NotificationTemplate.java @@ -10,7 +10,7 @@ public enum NotificationTemplate { // TOKEN 알림 TOKEN_CONVERTED(NotificationCategory.TOKEN, "토큰 전환 완료", "예금 %d원이 토큰으로 전환되었습니다.", false, true), - TOKEN_AUTO_CONVERTED(NotificationCategory.TOKEN, "토큰 자동 전환 완료", "예금 %d원이 토큰으로 전환되었습니다.", false, true), + TOKEN_AUTO_CONVERTED(NotificationCategory.TOKEN, "토큰 자동 전환 완료", "예금 %d원이 토큰으로 전환되었습니다.", true, true), DEPOSIT_CONVERTED(NotificationCategory.TOKEN, "예금 전환 완료", "토큰 %d TKT가 예금으로 전환되었습니다.", false, true), /** * USER 알림 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 c224ac9..d112ab7 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 @@ -46,11 +46,10 @@ public class NotificationServiceImpl implements NotificationService { @Transactional public void sendNotification(User user, NotificationTemplate template, Object... args) { - // 제목과 내용 생성 String title = template.getTitle(); String content = String.format(template.getContentTemplate(), args); + log.info("[SSE] sendNotification 호출됨 - userId={}, title={}", user.getId(), title); - // Notification 생성 및 저장 Notification notification = Notification.builder() .user(user) .category(template.getCategory()) @@ -60,28 +59,50 @@ public void sendNotification(User user, NotificationTemplate template, Object... notificationRepository.save(notification); + // SSE 전송 if (template.isSendSse()) { + log.info("[NOTI] SSE 전송 시도 - userId={}, title={}", user.getId(), title); boolean success = sseNotificationService.sendSse(user.getId(), title, content); - if (success) notification.markAsSentSse(); + if (success) { + log.info("[NOTI] SSE 전송 성공 - userId={}, title={}", user.getId(), title); + notification.markAsSentSse(); + } else { + log.warn("[NOTI] SSE 전송 실패 - userId={}, emitter 없음 또는 전송 실패", user.getId()); + } + } else { + log.info("[NOTI] 템플릿 설정상 SSE 미전송 - template={}", template.name()); } + // 이메일 전송 if (template.isSendEmail()) { boolean isEmailEnabled = notificationSettingRepository .findByUserAndCategory(user, template.getCategory()) .isEnabled(); + if (isEmailEnabled) { boolean success = emailNotificationService.sendEmail(user.getEmail(), title, content); - if (success) notification.markAsSentMail(); + if (success) { + log.info("[NOTI] 이메일 전송 성공 - userId={}, email={}", user.getId(), user.getEmail()); + notification.markAsSentMail(); + } else { + log.warn("[NOTI] 이메일 전송 실패 - userId={}, email={}", user.getId(), user.getEmail()); + } + } else { + log.info("[NOTI] 유저가 해당 카테고리 이메일 수신 거부 - userId={}, category={}", user.getId(), template.getCategory()); } } + // 최종 저장 notificationRepository.save(notification); } public SseEmitter subscribe(Long userId) { SseEmitter emitter = new SseEmitter(DEFAULT_TIMEOUT); sseEmitters.add(userId, emitter); + log.info("[SSE] 유저 {} 구독 등록됨", userId); + log.info("[SSE] 현재 emitter 등록 수: {}", sseEmitters.size()); + log.info("[SSE] 현재 등록된 emitter key 목록: {}", sseEmitters.keySet()); try { emitter.send(SseEmitter.event() @@ -90,12 +111,12 @@ public SseEmitter subscribe(Long userId) { } catch (IOException e) { emitter.completeWithError(e); sseEmitters.remove(userId); + log.warn("[SSE] connect 전송 실패로 emitter 제거됨 - userId={}", userId); } - // 트랜잭션 점유 방지를 위해 비동기로 분리된 메서드 호출 userRepository.findById(userId).ifPresent(this::sendUnsentNotificationsAsync); - // Ping 유지 + // ping 유지용 스케줄러 ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); scheduler.scheduleAtFixedRate(() -> { try { @@ -103,23 +124,29 @@ public SseEmitter subscribe(Long userId) { .name("ping") .data("keep-alive")); } catch (Exception e) { + log.warn("[SSE] ping 전송 실패 - emitter 제거됨: userId={}, error={}", userId, e.getMessage()); emitter.complete(); + sseEmitters.remove(userId); + scheduler.shutdown(); } }, 30, 30, TimeUnit.SECONDS); emitter.onCompletion(() -> { sseEmitters.remove(userId); scheduler.shutdown(); + log.info("[SSE] emitter 완료 처리됨 - userId={}", userId); }); emitter.onTimeout(() -> { sseEmitters.remove(userId); scheduler.shutdown(); + log.warn("[SSE] emitter 타임아웃 - userId={}", userId); }); emitter.onError((e) -> { sseEmitters.remove(userId); scheduler.shutdown(); + log.error("[SSE] emitter 에러 발생 - userId={}, error={}", userId, e.getMessage()); }); return emitter; @@ -128,7 +155,11 @@ public SseEmitter subscribe(Long userId) { @Async @Transactional(propagation = Propagation.REQUIRES_NEW) public void sendUnsentNotificationsAsync(User user) { - sendUnsentNotifications(user); + try { + sendUnsentNotifications(user); + } catch (Exception e) { + log.error("[SSE] 미발송 알림 전송 실패: userId={}, error={}", user.getId(), e.getMessage()); + } } @Transactional @@ -148,16 +179,29 @@ public void deleteNotification(Long notificationId, User user) { @Override public void sendUnsentNotifications(User user) { List unsentNotifications = notificationRepository.findByUserAndDeletedFalse(user); + log.info("[NOTI] 유저 {}의 미전송 알림 {}건 조회됨", user.getId(), unsentNotifications.size()); for (Notification notification : unsentNotifications) { + log.debug("[NOTI] 알림 id={}, title={}, sentSse={}, sentMail={}", + notification.getId(), + notification.getTitle(), + notification.isSentSse(), + notification.isSentMail()); - // 1. SSE 전송 (아직 안보냈고 카테고리 설정이 SYSTEM 또는 토큰 등 SSE 대상이라면) + // 1. SSE 전송 if (!notification.isSentSse()) { boolean success = sseNotificationService.sendSse(user.getId(), notification.getTitle(), notification.getContent()); - if (success) notification.markAsSentSse(); + if (success) { + log.info("[NOTI] SSE 전송 성공 - 알림 id={}, userId={}", notification.getId(), user.getId()); + notification.markAsSentSse(); + } else { + log.warn("[NOTI] SSE 전송 실패 - 알림 id={}, userId={}", notification.getId(), user.getId()); + } + } else { + log.debug("[NOTI] SSE 이미 전송됨 - 알림 id={}, userId={}", notification.getId(), user.getId()); } - // 2. 이메일 전송 (아직 안보냈고 사용자가 해당 카테고리 이메일 수신 동의한 경우) + // 2. 이메일 전송 if (!notification.isSentMail()) { NotificationCategorySetting setting = notificationSettingRepository .findByUserAndCategory(user, notification.getCategory()); @@ -168,8 +212,17 @@ public void sendUnsentNotifications(User user) { notification.getTitle(), notification.getContent() ); - if (success) notification.markAsSentMail(); + if (success) { + log.info("[NOTI] 이메일 전송 성공 - 알림 id={}, userId={}, email={}", notification.getId(), user.getId(), user.getEmail()); + notification.markAsSentMail(); + } else { + log.warn("[NOTI] 이메일 전송 실패 - 알림 id={}, userId={}, email={}", notification.getId(), user.getId(), user.getEmail()); + } + } else { + log.info("[NOTI] 이메일 수신 설정 안됨 - 알림 id={}, category={}, userId={}", notification.getId(), notification.getCategory(), user.getId()); } + } else { + log.debug("[NOTI] 이메일 이미 전송됨 - 알림 id={}, userId={}", notification.getId(), user.getId()); } } diff --git a/src/main/java/com/example/Tokkit_server/notification/service/SseNotificationService.java b/src/main/java/com/example/Tokkit_server/notification/service/SseNotificationService.java index 8a9e50c..8ee025a 100644 --- a/src/main/java/com/example/Tokkit_server/notification/service/SseNotificationService.java +++ b/src/main/java/com/example/Tokkit_server/notification/service/SseNotificationService.java @@ -8,6 +8,8 @@ import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.io.IOException; +import java.util.Map; +import java.util.UUID; import static org.apache.commons.lang3.StringEscapeUtils.escapeJson; @@ -20,22 +22,24 @@ public class SseNotificationService { public boolean sendSse(Long userId, String title, String content) { SseEmitter emitter = sseEmitters.get(userId); - if (emitter != null) { - try { - log.info("[SSE] 유저 {}에게 알림 전송 시도", userId); // 로그 추가 - String json = String.format("{\"title\": \"%s\", \"content\": \"%s\"}", title, content); - emitter.send(SseEmitter.event() - .name("notification") - .data(json, MediaType.APPLICATION_JSON)); - log.info("[SSE] 유저 {}에게 알림 전송 성공", userId); - return true; - } catch (IOException e) { - sseEmitters.remove(userId); - log.error("[SSE] 전송 실패 - emitter 제거됨: {}", e.getMessage()); - return false; - } - } else { - log.warn("[SSE] emitter 없음 → 전송 실패 (userId: {})", userId); // 로그 추가 + if (emitter == null) { + log.warn("❌ emitter 없음 - userId={}", userId); + return false; + } + + try { + SseEmitter.SseEventBuilder event = SseEmitter.event() + .name("notification") + .data(Map.of("title", title, "content", content)) // 프론트와 호환 + .id(UUID.randomUUID().toString()); + + emitter.send(event); + return true; + + } catch (IOException e) { + log.warn("❌ SSE 전송 실패 - userId={}, error={}", userId, e.getMessage()); + emitter.completeWithError(e); + sseEmitters.remove(userId); return false; } } diff --git a/src/main/java/com/example/Tokkit_server/user/utils/SseEmitters.java b/src/main/java/com/example/Tokkit_server/user/utils/SseEmitters.java index d62052d..a21ba3a 100644 --- a/src/main/java/com/example/Tokkit_server/user/utils/SseEmitters.java +++ b/src/main/java/com/example/Tokkit_server/user/utils/SseEmitters.java @@ -1,18 +1,28 @@ package com.example.Tokkit_server.user.utils; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; @Component +@Slf4j public class SseEmitters { private final Map emitters = new ConcurrentHashMap<>(); public void add(Long userId, SseEmitter emitter) { - emitters.put(userId, emitter); + SseEmitter oldEmitter = emitters.put(userId, emitter); + if (oldEmitter != null) { + try { + oldEmitter.complete(); + } catch (Exception e) { + log.warn("❗ 기존 emitter 종료 중 오류 - userId={}", userId, e); + } + } } public SseEmitter get(Long userId) { @@ -26,4 +36,14 @@ public void remove(Long userId) { public Map getEmitters() { return emitters; } + + // 현재 emitter 개수 확인용 + public int size() { + return emitters.size(); + } + + // 현재 등록된 유저 ID 목록 반환 + public Set keySet() { + return emitters.keySet(); + } } \ No newline at end of file diff --git a/src/main/java/com/example/Tokkit_server/wallet/scheduler/AutoTokenConvertScheduler.java b/src/main/java/com/example/Tokkit_server/wallet/scheduler/AutoTokenConvertScheduler.java index 5f51091..97a72d2 100644 --- a/src/main/java/com/example/Tokkit_server/wallet/scheduler/AutoTokenConvertScheduler.java +++ b/src/main/java/com/example/Tokkit_server/wallet/scheduler/AutoTokenConvertScheduler.java @@ -122,6 +122,8 @@ public void runMonthlyAutoConversion() { wallet.getUser().getId(), amount, receipt.getTransactionHash()); User user = wallet.getUser(); + log.info("[AUTO-CONVERT] 알림 전송 시도 - userId={}", user.getId()); + notificationService.sendNotification( user, NotificationTemplate.TOKEN_AUTO_CONVERTED, From 2d6c8e33cb64a341427e346b9886b31f21b16c86 Mon Sep 17 00:00:00 2001 From: noeyoes <> Date: Wed, 4 Jun 2025 14:34:26 +0900 Subject: [PATCH 33/34] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/NotificationRepository.java | 6 ++++ .../NotificationCleanupScheduler.java | 28 +++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 src/main/java/com/example/Tokkit_server/notification/scheduler/NotificationCleanupScheduler.java diff --git a/src/main/java/com/example/Tokkit_server/notification/repository/NotificationRepository.java b/src/main/java/com/example/Tokkit_server/notification/repository/NotificationRepository.java index 2776518..e2972b1 100644 --- a/src/main/java/com/example/Tokkit_server/notification/repository/NotificationRepository.java +++ b/src/main/java/com/example/Tokkit_server/notification/repository/NotificationRepository.java @@ -1,5 +1,6 @@ package com.example.Tokkit_server.notification.repository; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -7,6 +8,7 @@ import com.example.Tokkit_server.notification.enums.NotificationCategory; import com.example.Tokkit_server.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; @@ -23,4 +25,8 @@ public interface NotificationRepository extends JpaRepository findByIdAndUser(Long id, User user); List findByUserAndDeletedFalse(User user); + + @Modifying + @Query("UPDATE Notification n SET n.deleted = true WHERE n.deleted = false AND n.createdAt < :cutoff") + int softDeleteOldNotifications(@Param("cutoff") LocalDateTime cutoff); } \ No newline at end of file diff --git a/src/main/java/com/example/Tokkit_server/notification/scheduler/NotificationCleanupScheduler.java b/src/main/java/com/example/Tokkit_server/notification/scheduler/NotificationCleanupScheduler.java new file mode 100644 index 0000000..3507247 --- /dev/null +++ b/src/main/java/com/example/Tokkit_server/notification/scheduler/NotificationCleanupScheduler.java @@ -0,0 +1,28 @@ +package com.example.Tokkit_server.notification.scheduler; + +import com.example.Tokkit_server.notification.repository.NotificationRepository; +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; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Slf4j +@RequiredArgsConstructor +@Component +public class NotificationCleanupScheduler { + + private final NotificationRepository notificationRepository; + + // 매일 새벽 3시 실행 + @Scheduled(cron = "0 0 0 * * *") + @Transactional + public void cleanOldNotifications() { + LocalDateTime cutoff = LocalDate.now().minusDays(7).atStartOfDay(); + int updatedCount = notificationRepository.softDeleteOldNotifications(cutoff); + log.info("🧹 [알림 정리] {}개의 알림 soft delete 처리 완료", updatedCount); + } +} From a6dc62fa452c106a85544bc6f2a05153df544021 Mon Sep 17 00:00:00 2001 From: noeyoes <> Date: Wed, 4 Jun 2025 14:49:30 +0900 Subject: [PATCH 34/34] =?UTF-8?q?feat:=20=EA=B0=80=EB=A7=B9=EC=A0=90?= =?UTF-8?q?=EC=A3=BC=20=EC=95=8C=EB=A6=BC=20=EC=82=AD=EC=A0=9C=20=EC=8A=A4?= =?UTF-8?q?=EC=BC=80=EC=A4=84=EB=9F=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/MerchantNotificationRepository.java | 6 ++++++ .../scheduler/NotificationCleanupScheduler.java | 7 +++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/example/Tokkit_server/notification/repository/MerchantNotificationRepository.java b/src/main/java/com/example/Tokkit_server/notification/repository/MerchantNotificationRepository.java index fcd2c1d..3c8cd69 100644 --- a/src/main/java/com/example/Tokkit_server/notification/repository/MerchantNotificationRepository.java +++ b/src/main/java/com/example/Tokkit_server/notification/repository/MerchantNotificationRepository.java @@ -5,8 +5,10 @@ import com.example.Tokkit_server.notification.enums.NotificationCategory; import io.lettuce.core.dynamic.annotation.Param; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -20,4 +22,8 @@ public interface MerchantNotificationRepository extends JpaRepository findByIdAndMerchant(Long id, Merchant merchant); List findByMerchantAndDeletedFalse(Merchant merchant); + + @Modifying + @Query("UPDATE MerchantNotification n SET n.deleted = true WHERE n.deleted = false AND n.createdAt < :cutoff") + int softDeleteOldNotifications(@org.springframework.data.repository.query.Param("cutoff") LocalDateTime cutoff); } diff --git a/src/main/java/com/example/Tokkit_server/notification/scheduler/NotificationCleanupScheduler.java b/src/main/java/com/example/Tokkit_server/notification/scheduler/NotificationCleanupScheduler.java index 3507247..dba8295 100644 --- a/src/main/java/com/example/Tokkit_server/notification/scheduler/NotificationCleanupScheduler.java +++ b/src/main/java/com/example/Tokkit_server/notification/scheduler/NotificationCleanupScheduler.java @@ -1,5 +1,6 @@ package com.example.Tokkit_server.notification.scheduler; +import com.example.Tokkit_server.notification.repository.MerchantNotificationRepository; import com.example.Tokkit_server.notification.repository.NotificationRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -16,13 +17,15 @@ public class NotificationCleanupScheduler { private final NotificationRepository notificationRepository; + private final MerchantNotificationRepository merchantNotificationRepository; - // 매일 새벽 3시 실행 @Scheduled(cron = "0 0 0 * * *") @Transactional public void cleanOldNotifications() { LocalDateTime cutoff = LocalDate.now().minusDays(7).atStartOfDay(); int updatedCount = notificationRepository.softDeleteOldNotifications(cutoff); - log.info("🧹 [알림 정리] {}개의 알림 soft delete 처리 완료", updatedCount); + int updatedMerchantCount = merchantNotificationRepository.softDeleteOldNotifications(cutoff); + log.info("🧹 [유저 알림 정리] {}개의 알림 soft delete 처리 완료", updatedCount); + log.info("🧹 [가맹점주 알림 정리] {}개의 알림 soft delete 처리 완료", updatedMerchantCount); } }