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' 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..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 @@ -11,6 +11,9 @@ 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.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; @@ -42,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(); // 회원가입 @@ -104,6 +108,16 @@ 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(); + notificationSettingRepository.save(setting); + } + 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/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/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..08f5be0 --- /dev/null +++ b/src/main/java/com/example/Tokkit_server/notification/entity/MerchantNotification.java @@ -0,0 +1,50 @@ +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 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/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/enums/NotificationTemplate.java b/src/main/java/com/example/Tokkit_server/notification/enums/NotificationTemplate.java index 43d4c92..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,6 +10,7 @@ public enum NotificationTemplate { // TOKEN 알림 TOKEN_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/repository/MerchantNotificationRepository.java b/src/main/java/com/example/Tokkit_server/notification/repository/MerchantNotificationRepository.java new file mode 100644 index 0000000..3c8cd69 --- /dev/null +++ b/src/main/java/com/example/Tokkit_server/notification/repository/MerchantNotificationRepository.java @@ -0,0 +1,29 @@ +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.Modifying; +import org.springframework.data.jpa.repository.Query; + +import java.time.LocalDateTime; +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); + + @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/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/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..dba8295 --- /dev/null +++ b/src/main/java/com/example/Tokkit_server/notification/scheduler/NotificationCleanupScheduler.java @@ -0,0 +1,31 @@ +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; +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; + private final MerchantNotificationRepository merchantNotificationRepository; + + @Scheduled(cron = "0 0 0 * * *") + @Transactional + public void cleanOldNotifications() { + LocalDateTime cutoff = LocalDate.now().minusDays(7).atStartOfDay(); + int updatedCount = notificationRepository.softDeleteOldNotifications(cutoff); + int updatedMerchantCount = merchantNotificationRepository.softDeleteOldNotifications(cutoff); + log.info("🧹 [유저 알림 정리] {}개의 알림 soft delete 처리 완료", updatedCount); + log.info("🧹 [가맹점주 알림 정리] {}개의 알림 soft delete 처리 완료", updatedMerchantCount); + } +} 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 +} 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/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())); + } + } + } + +} 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..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 @@ -144,41 +175,33 @@ 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) { 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()); @@ -189,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()); } } @@ -223,16 +255,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()); - } } 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/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; } 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 } 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/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/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) { 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..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 @@ -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,22 @@ 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); + } + + + @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 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 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..72d6566 --- /dev/null +++ b/src/main/java/com/example/Tokkit_server/wallet/dto/response/AutoConvertSettingResponse.java @@ -0,0 +1,16 @@ +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 int minute; + private long amount; +} \ No newline at end of file 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 이거나 둘 다의 값이 들어가는 경우를 방지 (유효성 검증) */ 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 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..97a72d2 --- /dev/null +++ b/src/main/java/com/example/Tokkit_server/wallet/scheduler/AutoTokenConvertScheduler.java @@ -0,0 +1,138 @@ +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 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; +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; + private final NotificationService notificationService; + + /** + * 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()); + + User user = wallet.getUser(); + log.info("[AUTO-CONVERT] 알림 전송 시도 - userId={}", user.getId()); + + notificationService.sendNotification( + user, + NotificationTemplate.TOKEN_AUTO_CONVERTED, + amount + ); + + } catch (Exception e) { + log.error("자동 전환 실패: userId={}", wallet.getUser().getId(), e); + } + } + } +} \ No newline at end of file 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..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 @@ -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, @@ -64,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..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 @@ -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; @@ -13,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; @@ -61,6 +63,7 @@ public class WalletCommandService { private final TokkitTokenService tokkitTokenService; private final StoreRepository storeRepository; private final NotificationService notificationService; + private final MerchantNotificationService merchantNotificationService; /** * txHash 없는 기본형 @@ -108,7 +111,7 @@ private void logAndSave(Wallet wallet, Long userId, Long merchantId, @Transactional public Wallet createInitialWalletForUser(Long userId) { if (walletRepository.existsByUserId(userId)) { - return walletRepository.findByUserId(userId) + return walletRepository.findByUser_Id(userId) .orElseThrow(() -> new GeneralException(ErrorStatus.USER_WALLET_NOT_FOUND)); } @@ -126,6 +129,7 @@ public Wallet createInitialWalletForUser(Long userId) { .walletType(WalletType.USER) .accountNumber(AccountGenerator.generateAccountNumber()) .walletAddress(walletAddress) + .autoConvertEnabled(false) .build(); return walletRepository.save(wallet); @@ -138,7 +142,7 @@ public Wallet createInitialWalletForUser(Long userId) { @Transactional public Wallet createInitialWalletForMerchant(Long merchantId) { if (walletRepository.existsByMerchantId(merchantId)) { - return walletRepository.findByMerchantId(merchantId) + return walletRepository.findByMerchant_Id(merchantId) .orElseThrow(() -> new GeneralException(ErrorStatus.MERCHANT_WALLET_NOT_FOUND)); } @@ -156,6 +160,7 @@ public Wallet createInitialWalletForMerchant(Long merchantId) { .walletType(WalletType.MERCHANT) .accountNumber(AccountGenerator.generateAccountNumber()) .walletAddress(walletAddress) + .autoConvertEnabled(false) .build(); return walletRepository.save(wallet); @@ -342,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, @@ -372,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, @@ -387,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() @@ -470,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, @@ -478,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, @@ -492,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() 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); } } - - } 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..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 @@ -11,8 +11,10 @@ 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.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; @@ -25,7 +27,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 +228,39 @@ 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() + ); + + } + + /** + * 변환 예약 상태 확인 + */ + 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