diff --git a/build.gradle b/build.gradle index 909410ed..12843352 100644 --- a/build.gradle +++ b/build.gradle @@ -74,7 +74,7 @@ dependencies { implementation 'com.google.firebase:firebase-admin:9.2.0' // slack-webhook - implementation "net.gpedro.integrations.slack:slack-webhook:1.4.0" + implementation "com.slack.api:slack-api-client:1.38.3" // google play android developer api implementation 'com.google.apis:google-api-services-androidpublisher:v3-rev20211125-1.32.1' diff --git a/lombok.config b/lombok.config new file mode 100644 index 00000000..53a4a723 --- /dev/null +++ b/lombok.config @@ -0,0 +1 @@ +lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier diff --git a/src/main/java/com/yello/server/domain/authorization/controller/AuthController.java b/src/main/java/com/yello/server/domain/authorization/controller/AuthController.java index f1d68052..46c5dcd3 100644 --- a/src/main/java/com/yello/server/domain/authorization/controller/AuthController.java +++ b/src/main/java/com/yello/server/domain/authorization/controller/AuthController.java @@ -24,7 +24,9 @@ import com.yello.server.domain.group.entity.UserGroupType; import com.yello.server.global.common.annotation.ServiceToken; import com.yello.server.global.common.dto.BaseResponse; -import com.yello.server.infrastructure.slack.annotation.SlackSignUpNotification; +import com.yello.server.infrastructure.slack.dto.response.SlackChannel; +import com.yello.server.infrastructure.slack.service.SlackService; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import lombok.RequiredArgsConstructor; @@ -42,6 +44,7 @@ public class AuthController { private final AuthService authService; + private final SlackService slackService; @PostMapping("/oauth") public BaseResponse oauthLogin(@RequestBody OAuthRequest oAuthRequest) { @@ -56,10 +59,10 @@ public BaseResponse getYelloIdValidation(@RequestParam("yelloId") Strin } @PostMapping("/signup") - @SlackSignUpNotification public BaseResponse postSignUp( - @Valid @RequestBody SignUpRequest signUpRequest) { + @Valid @RequestBody SignUpRequest signUpRequest, HttpServletRequest servletRequest) throws Exception { val data = authService.signUp(signUpRequest); + slackService.sendSignUpMessage(SlackChannel.SIGN_UP, servletRequest); return BaseResponse.success(SIGN_UP_SUCCESS, data); } diff --git a/src/main/java/com/yello/server/domain/purchase/controller/PurchaseController.java b/src/main/java/com/yello/server/domain/purchase/controller/PurchaseController.java index a05fc983..e5c9f344 100644 --- a/src/main/java/com/yello/server/domain/purchase/controller/PurchaseController.java +++ b/src/main/java/com/yello/server/domain/purchase/controller/PurchaseController.java @@ -14,11 +14,14 @@ import com.yello.server.domain.purchase.dto.response.GoogleTicketGetResponse; import com.yello.server.domain.purchase.dto.response.UserPurchaseInfoResponse; import com.yello.server.domain.purchase.dto.response.UserSubscribeNeededResponse; +import com.yello.server.domain.purchase.entity.Purchase; import com.yello.server.domain.purchase.service.PurchaseService; import com.yello.server.domain.user.entity.User; import com.yello.server.global.common.annotation.AccessTokenUser; import com.yello.server.global.common.dto.BaseResponse; -import com.yello.server.infrastructure.slack.annotation.SlackPurchaseNotification; +import com.yello.server.infrastructure.slack.dto.response.SlackChannel; +import com.yello.server.infrastructure.slack.service.SlackService; +import jakarta.servlet.http.HttpServletRequest; import java.io.IOException; import java.time.LocalDateTime; import lombok.RequiredArgsConstructor; @@ -36,44 +39,57 @@ public class PurchaseController { private final PurchaseService purchaseService; + private final SlackService slackService; @PostMapping("/apple/verify/subscribe") - @SlackPurchaseNotification public BaseResponse verifyAppleSubscriptionTransaction( @RequestBody AppleTransaction appleTransaction, - @AccessTokenUser User user - ) { + @AccessTokenUser User user, + HttpServletRequest servletRequest + ) throws IOException { purchaseService.verifyAppleSubscriptionTransaction(user.getId(), appleTransaction); + final Purchase purchase = purchaseService.getByTransactionId(appleTransaction.transactionId()); + + slackService.sendPurchaseMessage(SlackChannel.PURCHASE, servletRequest, purchase); return BaseResponse.success(VERIFY_RECEIPT_SUCCESS); } @PostMapping("/apple/verify/ticket") - @SlackPurchaseNotification public BaseResponse verifyAppleTicketTransaction( @RequestBody AppleTransaction appleTransaction, - @AccessTokenUser User user - ) { + @AccessTokenUser User user, + HttpServletRequest servletRequest + ) throws IOException { purchaseService.verifyAppleTicketTransaction(user.getId(), appleTransaction); + final Purchase purchase = purchaseService.getByTransactionId(appleTransaction.transactionId()); + + slackService.sendPurchaseMessage(SlackChannel.PURCHASE, servletRequest, purchase); return BaseResponse.success(VERIFY_RECEIPT_SUCCESS); } @PostMapping("/google/verify/subscribe") - @SlackPurchaseNotification public BaseResponse verifyGoogleSubscriptionTransaction( @AccessTokenUser User user, - @RequestBody GoogleSubscriptionGetRequest request + @RequestBody GoogleSubscriptionGetRequest request, + HttpServletRequest servletRequest ) throws IOException { val data = purchaseService.verifyGoogleSubscriptionTransaction(user.getId(), request); + final Purchase purchase = purchaseService.getByTransactionId(request.orderId()); + + slackService.sendPurchaseMessage(SlackChannel.PURCHASE, servletRequest, purchase); return BaseResponse.success(GOOGLE_PURCHASE_SUBSCRIPTION_VERIFY_SUCCESS, data); } @PostMapping("/google/verify/ticket") - @SlackPurchaseNotification public BaseResponse verifyGoogleTicketTransaction( @AccessTokenUser User user, - @RequestBody GoogleTicketGetRequest request + @RequestBody GoogleTicketGetRequest request, + HttpServletRequest servletRequest ) throws IOException { val data = purchaseService.verifyGoogleTicketTransaction(user.getId(), request); + final Purchase purchase = purchaseService.getByTransactionId(request.orderId()); + + slackService.sendPurchaseMessage(SlackChannel.PURCHASE, servletRequest, purchase); return BaseResponse.success(GOOGLE_PURCHASE_INAPP_VERIFY_SUCCESS, data); } diff --git a/src/main/java/com/yello/server/domain/purchase/controller/PurchaseNotificationController.java b/src/main/java/com/yello/server/domain/purchase/controller/PurchaseNotificationController.java index d94083ae..d1a43b76 100644 --- a/src/main/java/com/yello/server/domain/purchase/controller/PurchaseNotificationController.java +++ b/src/main/java/com/yello/server/domain/purchase/controller/PurchaseNotificationController.java @@ -5,10 +5,18 @@ import com.yello.server.domain.purchase.dto.request.AppleNotificationRequest; import com.yello.server.domain.purchase.dto.request.GooglePubSubNotificationRequest; +import com.yello.server.domain.purchase.dto.request.google.DeveloperNotification; +import com.yello.server.domain.purchase.entity.Purchase; import com.yello.server.domain.purchase.service.PurchaseService; import com.yello.server.global.common.dto.BaseResponse; import com.yello.server.global.common.dto.EmptyObject; -import com.yello.server.infrastructure.slack.annotation.SlackApplePurchaseNotification; +import com.yello.server.infrastructure.slack.dto.response.SlackChannel; +import com.yello.server.infrastructure.slack.service.SlackService; +import jakarta.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.net.URISyntaxException; +import java.util.Map; +import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.PostMapping; @@ -21,21 +29,25 @@ public class PurchaseNotificationController { private final PurchaseService purchaseService; + private final SlackService slackService; @PostMapping("/v2/apple/notifications") - @SlackApplePurchaseNotification - public BaseResponse appleNotification( - @RequestBody AppleNotificationRequest request - ) { + public BaseResponse appleNotification(@RequestBody AppleNotificationRequest request, + HttpServletRequest servletRequest) throws IOException, URISyntaxException { purchaseService.appleNotification(request); + + slackService.sendAppleStateChangedMessage(SlackChannel.PURCHASE, servletRequest); return BaseResponse.success(POST_APPLE_NOTIFICATION_SUCCESS); } @PostMapping("/v2/google/notifications") public BaseResponse googleNotification( - @RequestBody GooglePubSubNotificationRequest request - ) { - purchaseService.googleNotification(request); + @RequestBody GooglePubSubNotificationRequest request, HttpServletRequest servletRequest + ) throws IOException, URISyntaxException { + final Map.Entry> notification = purchaseService.googleNotification( + request); + + slackService.sendGoogleStateChangedMessage(SlackChannel.PURCHASE, servletRequest, notification); return BaseResponse.success(POST_GOOGLE_NOTIFICATION_SUCCESS, EmptyObject.builder().build()); } } diff --git a/src/main/java/com/yello/server/domain/purchase/repository/PurchaseRepository.java b/src/main/java/com/yello/server/domain/purchase/repository/PurchaseRepository.java index 2862d41d..b2119535 100644 --- a/src/main/java/com/yello/server/domain/purchase/repository/PurchaseRepository.java +++ b/src/main/java/com/yello/server/domain/purchase/repository/PurchaseRepository.java @@ -14,6 +14,8 @@ public interface PurchaseRepository { Optional findByTransactionId(String transactionId); + Purchase getByTransactionId(String transactionId); + Optional findByPurchaseToken(String purchaseToken); List findAllByUser(User user); diff --git a/src/main/java/com/yello/server/domain/purchase/repository/PurchaseRepositoryImpl.java b/src/main/java/com/yello/server/domain/purchase/repository/PurchaseRepositoryImpl.java index 15487d9e..78d5e2e8 100644 --- a/src/main/java/com/yello/server/domain/purchase/repository/PurchaseRepositoryImpl.java +++ b/src/main/java/com/yello/server/domain/purchase/repository/PurchaseRepositoryImpl.java @@ -1,12 +1,12 @@ package com.yello.server.domain.purchase.repository; +import static com.yello.server.global.common.ErrorCode.NOT_EQUAL_TRANSACTION_EXCEPTION; import static com.yello.server.global.common.ErrorCode.NOT_FOUND_USER_SUBSCRIBE_EXCEPTION; import com.yello.server.domain.purchase.entity.ProductType; import com.yello.server.domain.purchase.entity.Purchase; import com.yello.server.domain.purchase.exception.PurchaseNotFoundException; import com.yello.server.domain.user.entity.User; -import com.yello.server.global.common.ErrorCode; import java.util.List; import java.util.Optional; import lombok.RequiredArgsConstructor; @@ -33,6 +33,12 @@ public Optional findByTransactionId(String transactionId) { return purchaseJpaRepository.findByTransactionId(transactionId); } + @Override + public Purchase getByTransactionId(String transactionId) { + return purchaseJpaRepository.findByTransactionId(transactionId) + .orElseThrow(() -> new PurchaseNotFoundException(NOT_EQUAL_TRANSACTION_EXCEPTION)); + } + @Override public Optional findByPurchaseToken(String purchaseToken) { return purchaseJpaRepository.findByPurchaseToken(purchaseToken); diff --git a/src/main/java/com/yello/server/domain/purchase/service/PurchaseService.java b/src/main/java/com/yello/server/domain/purchase/service/PurchaseService.java index 7001db8a..8708ef8c 100644 --- a/src/main/java/com/yello/server/domain/purchase/service/PurchaseService.java +++ b/src/main/java/com/yello/server/domain/purchase/service/PurchaseService.java @@ -20,7 +20,16 @@ import static com.yello.server.global.common.ErrorCode.NOT_FOUND_TRANSACTION_EXCEPTION; import static com.yello.server.global.common.ErrorCode.PURCHASE_TOKEN_NOT_FOUND_PURCHASE_EXCEPTION; import static com.yello.server.global.common.ErrorCode.SUBSCRIBE_ACTIVE_EXCEPTION; -import static com.yello.server.global.common.util.ConstantUtil.*; +import static com.yello.server.global.common.util.ConstantUtil.APPLE_NOTIFICATION_DID_RENEW; +import static com.yello.server.global.common.util.ConstantUtil.APPLE_NOTIFICATION_EXPIRED; +import static com.yello.server.global.common.util.ConstantUtil.APPLE_NOTIFICATION_REFUND; +import static com.yello.server.global.common.util.ConstantUtil.APPLE_NOTIFICATION_SUBSCRIBED; +import static com.yello.server.global.common.util.ConstantUtil.APPLE_NOTIFICATION_SUBSCRIPTION_STATUS_CHANGE; +import static com.yello.server.global.common.util.ConstantUtil.APPLE_NOTIFICATION_TEST; +import static com.yello.server.global.common.util.ConstantUtil.FIVE_TICKET_ID; +import static com.yello.server.global.common.util.ConstantUtil.ONE_TICKET_ID; +import static com.yello.server.global.common.util.ConstantUtil.TWO_TICKET_ID; +import static com.yello.server.global.common.util.ConstantUtil.YELLO_PLUS_ID; import com.google.gson.Gson; import com.google.gson.JsonObject; @@ -64,6 +73,7 @@ import java.time.Duration; import java.time.LocalDateTime; import java.util.Base64; +import java.util.Map; import java.util.Objects; import java.util.Optional; import lombok.Builder; @@ -330,7 +340,7 @@ public void appleNotification(AppleNotificationRequest request) { case APPLE_NOTIFICATION_SUBSCRIPTION_STATUS_CHANGE -> { purchaseManager.changeSubscriptionStatus(payloadVO); } - case APPLE_NOTIFICATION_EXPIRED ->{ + case APPLE_NOTIFICATION_EXPIRED -> { purchaseManager.expiredSubscribe(payloadVO); } case APPLE_NOTIFICATION_REFUND -> { @@ -349,7 +359,8 @@ public void appleNotification(AppleNotificationRequest request) { } @Transactional - public void googleNotification(GooglePubSubNotificationRequest request) { + public Map.Entry> googleNotification( + GooglePubSubNotificationRequest request) { if (!Objects.equals(request.subscription(), googlePubSubName)) { log.info("BAD REQUEST by wrong pub-sub name"); throw new GoogleBadRequestException(GOOGLE_NOTIFICATION_BAD_REQUEST_EXCEPTION); @@ -363,6 +374,7 @@ public void googleNotification(GooglePubSubNotificationRequest request) { ); final DeveloperNotification developerNotification = gson.fromJson(data, DeveloperNotification.class); + Optional purchase = Optional.empty(); if (ObjectUtils.isEmpty(developerNotification.getProductType())) { log.info("BAD REQUEST by wrong pub-sub name"); @@ -374,10 +386,9 @@ public void googleNotification(GooglePubSubNotificationRequest request) { case TEST -> { log.info("TEST notification is sent by Google Play Console"); - return; } case YELLO_PLUS -> { - final Optional purchase = purchaseRepository.findByPurchaseToken( + purchase = purchaseRepository.findByPurchaseToken( developerNotification.subscriptionNotification().purchaseToken()); if (purchase.isEmpty()) { @@ -414,12 +425,11 @@ public void googleNotification(GooglePubSubNotificationRequest request) { } case SUBSCRIPTION_PRICE_CHANGE_CONFIRMED -> { // NOTHING - return; } } } case ONE_TICKET, TWO_TICKET, FIVE_TICKET -> { - final Optional purchase = purchaseRepository.findByPurchaseToken( + purchase = purchaseRepository.findByPurchaseToken( developerNotification.oneTimeProductNotification().purchaseToken()); if (purchase.isEmpty()) { @@ -445,5 +455,10 @@ public void googleNotification(GooglePubSubNotificationRequest request) { } } } + return Map.entry(developerNotification, purchase); + } + + public Purchase getByTransactionId(String transactionId) { + return purchaseRepository.getByTransactionId(transactionId); } } diff --git a/src/main/java/com/yello/server/domain/statistics/dto/SignUpVO.java b/src/main/java/com/yello/server/domain/statistics/dto/SignUpVO.java new file mode 100644 index 00000000..1c6cb16d --- /dev/null +++ b/src/main/java/com/yello/server/domain/statistics/dto/SignUpVO.java @@ -0,0 +1,12 @@ +package com.yello.server.domain.statistics.dto; + +import lombok.Builder; + +@Builder +public record SignUpVO( + Long count, + Long maleCount, + Long femaleCount +) { + +} diff --git a/src/main/java/com/yello/server/domain/statistics/entity/StatisticsDaily.java b/src/main/java/com/yello/server/domain/statistics/entity/StatisticsDaily.java new file mode 100644 index 00000000..bdd6b3b8 --- /dev/null +++ b/src/main/java/com/yello/server/domain/statistics/entity/StatisticsDaily.java @@ -0,0 +1,58 @@ +package com.yello.server.domain.statistics.entity; + +import com.yello.server.domain.statistics.dto.SignUpVO; +import com.yello.server.global.common.entity.JsonConverter; +import com.yello.server.global.common.entity.ZonedDateTimeConverter; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import java.time.ZonedDateTime; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; +import org.hibernate.annotations.DynamicInsert; + +@Entity +@Getter +@SuperBuilder +@DynamicInsert +@AllArgsConstructor +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class StatisticsDaily { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + @Convert(converter = ZonedDateTimeConverter.class) + private ZonedDateTime startAt; + + @Column(nullable = false) + @Convert(converter = ZonedDateTimeConverter.class) + private ZonedDateTime endAt; + + /** + * json + */ + @Column + @Convert(converter = JsonConverter.class) + private SignUpVO signUp; + + /** + * json + */ + @Column + private String revenue; + + /** + * json + */ + @Column + private String vote; +} diff --git a/src/main/java/com/yello/server/domain/statistics/repository/StatisticsDailyJpaRepository.java b/src/main/java/com/yello/server/domain/statistics/repository/StatisticsDailyJpaRepository.java new file mode 100644 index 00000000..5eacd54b --- /dev/null +++ b/src/main/java/com/yello/server/domain/statistics/repository/StatisticsDailyJpaRepository.java @@ -0,0 +1,8 @@ +package com.yello.server.domain.statistics.repository; + +import com.yello.server.domain.statistics.entity.StatisticsDaily; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface StatisticsDailyJpaRepository extends JpaRepository { + +} diff --git a/src/main/java/com/yello/server/domain/statistics/repository/StatisticsRepository.java b/src/main/java/com/yello/server/domain/statistics/repository/StatisticsRepository.java index 7cfa05ab..efa560d0 100644 --- a/src/main/java/com/yello/server/domain/statistics/repository/StatisticsRepository.java +++ b/src/main/java/com/yello/server/domain/statistics/repository/StatisticsRepository.java @@ -1,6 +1,7 @@ package com.yello.server.domain.statistics.repository; import com.yello.server.domain.statistics.dto.NewStatisticsUserGroupVO; +import com.yello.server.domain.statistics.entity.StatisticsDaily; import com.yello.server.domain.statistics.entity.StatisticsUserGroup; import java.time.LocalDateTime; import java.util.List; @@ -26,4 +27,8 @@ public interface StatisticsRepository { List getSchoolAttackStatistics(Pageable pageable); List getSchoolAttackStatisticsContaining(String groupName, Pageable pageable); + + StatisticsDaily save(StatisticsDaily newStatisticsDaily); + + StatisticsDaily getById(Long id); } diff --git a/src/main/java/com/yello/server/domain/statistics/repository/StatisticsRepositoryImpl.java b/src/main/java/com/yello/server/domain/statistics/repository/StatisticsRepositoryImpl.java index 652a9b0f..502a130b 100644 --- a/src/main/java/com/yello/server/domain/statistics/repository/StatisticsRepositoryImpl.java +++ b/src/main/java/com/yello/server/domain/statistics/repository/StatisticsRepositoryImpl.java @@ -3,6 +3,7 @@ import static com.yello.server.global.common.ErrorCode.STATISTICS_NOT_FOUND_EXCEPTION; import com.yello.server.domain.statistics.dto.NewStatisticsUserGroupVO; +import com.yello.server.domain.statistics.entity.StatisticsDaily; import com.yello.server.domain.statistics.entity.StatisticsUserGroup; import com.yello.server.domain.statistics.exception.StatisticsNotFoundException; import java.time.LocalDateTime; @@ -16,6 +17,7 @@ @RequiredArgsConstructor public class StatisticsRepositoryImpl implements StatisticsRepository { + private final StatisticsDailyJpaRepository statisticsDailyJpaRepository; private final StatisticsUserGroupJpaRepository userGroupJpaRepository; @Override @@ -63,4 +65,15 @@ public List getSchoolAttackStatistics(Pageable pageable) { public List getSchoolAttackStatisticsContaining(String groupName, Pageable pageable) { return userGroupJpaRepository.getSchoolAttackStatisticsContaining(groupName, pageable); } + + @Override + public StatisticsDaily save(StatisticsDaily newStatisticsDaily) { + return statisticsDailyJpaRepository.save(newStatisticsDaily); + } + + @Override + public StatisticsDaily getById(Long id) { + return statisticsDailyJpaRepository.findById(id) + .orElseThrow(() -> new StatisticsNotFoundException(STATISTICS_NOT_FOUND_EXCEPTION)); + } } diff --git a/src/main/java/com/yello/server/domain/statistics/service/StatisticsService.java b/src/main/java/com/yello/server/domain/statistics/service/StatisticsService.java index e0391778..1fc8d053 100644 --- a/src/main/java/com/yello/server/domain/statistics/service/StatisticsService.java +++ b/src/main/java/com/yello/server/domain/statistics/service/StatisticsService.java @@ -2,10 +2,16 @@ import com.yello.server.domain.statistics.dto.NewStatisticsUserGroupVO; import com.yello.server.domain.statistics.dto.SchoolAttackStatisticsVO; +import com.yello.server.domain.statistics.dto.SignUpVO; import com.yello.server.domain.statistics.dto.response.StatisticsUserGroupSchoolAttackResponse; +import com.yello.server.domain.statistics.entity.StatisticsDaily; import com.yello.server.domain.statistics.entity.StatisticsUserGroup; import com.yello.server.domain.statistics.repository.StatisticsRepository; +import com.yello.server.domain.user.entity.Gender; +import com.yello.server.domain.user.repository.UserRepository; +import com.yello.server.global.common.util.ConstantUtil; import java.time.LocalDateTime; +import java.time.ZonedDateTime; import java.util.List; import java.util.Optional; import lombok.RequiredArgsConstructor; @@ -19,6 +25,7 @@ public class StatisticsService { private final StatisticsRepository statisticsRepository; + private final UserRepository userRepository; @Transactional public void writeUserGroupStatistics() { @@ -93,4 +100,24 @@ public StatisticsUserGroupSchoolAttackResponse getSchoolAttackStatisticsLikeGrou .statisticsList(statistics.stream().map(SchoolAttackStatisticsVO::of).toList()) .build(); } + + @Transactional + public StatisticsDaily writeDailyServiceStatistics() { + final ZonedDateTime now = ZonedDateTime.now(ConstantUtil.GlobalZoneId); + final Long count = userRepository.count(); + final Long countFemale = userRepository.countAllByGender(Gender.FEMALE); + final Long countMale = userRepository.countAllByGender(Gender.MALE); + final SignUpVO signUpVO = SignUpVO.builder() + .count(count) + .femaleCount(countFemale) + .maleCount(countMale) + .build(); + final StatisticsDaily saved = statisticsRepository.save(StatisticsDaily.builder() + .startAt(now.minusDays(1)) + .endAt(now) + .signUp(signUpVO) + .build()); + + return statisticsRepository.getById(saved.getId()); + } } diff --git a/src/main/java/com/yello/server/domain/statistics/task/StatisticsTask.java b/src/main/java/com/yello/server/domain/statistics/task/StatisticsTask.java index 2da6f3f4..9dfcb4f0 100644 --- a/src/main/java/com/yello/server/domain/statistics/task/StatisticsTask.java +++ b/src/main/java/com/yello/server/domain/statistics/task/StatisticsTask.java @@ -2,6 +2,9 @@ import com.yello.server.domain.statistics.service.StatisticsService; import com.yello.server.global.common.util.ConstantUtil; +import com.yello.server.infrastructure.slack.factory.SlackWebhookMessageFactory; +import com.yello.server.infrastructure.slack.service.SlackService; +import java.io.IOException; import lombok.RequiredArgsConstructor; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @@ -10,10 +13,18 @@ @RequiredArgsConstructor public class StatisticsTask { + private final SlackWebhookMessageFactory messageFactory; + private final SlackService slackService; private final StatisticsService statisticsService; @Scheduled(cron = "0 */30 * * * *", zone = ConstantUtil.GlobalZoneIdLabel) public void writeUserGroupStatistics() { statisticsService.writeUserGroupStatistics(); } + + @Scheduled(cron = "*/10 * * * * *", zone = ConstantUtil.GlobalZoneIdLabel) + public void calculateDailyStatistics() throws IOException { +// final StatisticsDaily statisticsDaily = statisticsService.writeDailyServiceStatistics(); +// slackService.send(SlackChannel.DATA_DAILY, messageFactory.generateDataDailyPayload(statisticsDaily)); + } } diff --git a/src/main/java/com/yello/server/domain/user/repository/UserJpaRepository.java b/src/main/java/com/yello/server/domain/user/repository/UserJpaRepository.java index f6299dce..c89c6d95 100644 --- a/src/main/java/com/yello/server/domain/user/repository/UserJpaRepository.java +++ b/src/main/java/com/yello/server/domain/user/repository/UserJpaRepository.java @@ -1,128 +1,130 @@ package com.yello.server.domain.user.repository; +import com.yello.server.domain.user.entity.Gender; import com.yello.server.domain.user.entity.User; +import java.util.List; +import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import java.util.List; -import java.util.Optional; - public interface UserJpaRepository extends JpaRepository { @Query("select u from User u " + - "where u.id = :id " + - "and u.deletedAt is null") + "where u.id = :id " + + "and u.deletedAt is null") Optional findById(@Param("id") Long id); @Query("select u from User u " + - "where u.id = :id") + "where u.id = :id") Optional findByIdNotFiltered(@Param("id") Long id); @Query("select u from User u " + - "where u.uuid = :uuid") + "where u.uuid = :uuid") Optional findByUuid(@Param("uuid") String uuid); @Query("select u from User u " + - "where u.uuid = :uuid") + "where u.uuid = :uuid") Optional findByUuidNotFiltered(@Param("uuid") String uuid); @Query("select case when count(u) > 0 then true else false end from User u " + - "where u.uuid = :uuid " + - "and u.deletedAt is null") + "where u.uuid = :uuid " + + "and u.deletedAt is null") boolean existsByUuid(@Param("uuid") String uuid); @Query("select u from User u " + - "where u.yelloId = :yelloId " + - "and u.deletedAt is null") + "where u.yelloId = :yelloId " + + "and u.deletedAt is null") Optional findByYelloId(@Param("yelloId") String yelloId); @Query("select u from User u " + - "where u.yelloId = :yelloId") + "where u.yelloId = :yelloId") Optional findByYelloIdNotFiltered(@Param("yelloId") String yelloId); @Query("select u from User u, UserGroup g " + - "where u.group.id = g.id " + - "and g.id = :groupId " + - "and u.deletedAt is null") + "where u.group.id = g.id " + + "and g.id = :groupId " + + "and u.deletedAt is null") List findAllByGroupId(@Param("groupId") Long groupId); @Query("select count (u) from User u, UserGroup g " + - "where u.group.id = g.id " + - "and g.groupName = :groupName " + - "and u.id <> :userId " + - "and u.id not in (select f.target.id from Friend f where :userId = f.user.id and f.target.deletedAt is null) " - + - "and u.deletedAt is null") + "where u.group.id = g.id " + + "and g.groupName = :groupName " + + "and u.id <> :userId " + + "and u.id not in (select f.target.id from Friend f where :userId = f.user.id and f.target.deletedAt is null) " + + + "and u.deletedAt is null") Integer countAllByGroupNameFilteredByNotFriend(@Param("userId") Long userId, @Param("groupName") String groupName); @Query("select u from User u, UserGroup g " + - "where u.group.id = g.id " + - "and g.groupName = :groupName " + - "and u.id <> :userId " + - "and u.id not in (select f.target.id from Friend f where :userId = f.user.id and f.target.deletedAt is null) " - + - "and u.deletedAt is null") + "where u.group.id = g.id " + + "and g.groupName = :groupName " + + "and u.id <> :userId " + + "and u.id not in (select f.target.id from Friend f where :userId = f.user.id and f.target.deletedAt is null) " + + + "and u.deletedAt is null") List findAllByGroupNameFilteredByNotFriend(@Param("userId") Long userId, - @Param("groupName") String groupName, Pageable pageable); + @Param("groupName") String groupName, Pageable pageable); @Query("select u from User u " - + "where u.group.groupName = :groupName " - + "and u.uuid not in :uuidList " - + "and u.name like CONCAT('%', :keyword, '%') " - + "and u.deletedAt is null " - + "order by u.name ASC ") + + "where u.group.groupName = :groupName " + + "and u.uuid not in :uuidList " + + "and u.name like CONCAT('%', :keyword, '%') " + + "and u.deletedAt is null " + + "order by u.name ASC ") List findAllByGroupContainingName(@Param("groupName") String groupName, - @Param("keyword") String keyword, @Param("uuidList") List uuidList); + @Param("keyword") String keyword, @Param("uuidList") List uuidList); @Query("select u from User u " - + "where u.group.groupName <> :groupName " - + "and u.uuid not in :uuidList " - + "and u.name like CONCAT('%', :keyword, '%') " - + "and u.deletedAt is null " - + "order by u.groupAdmissionYear DESC ") + + "where u.group.groupName <> :groupName " + + "and u.uuid not in :uuidList " + + "and u.name like CONCAT('%', :keyword, '%') " + + "and u.deletedAt is null " + + "order by u.groupAdmissionYear DESC ") List findAllByOtherGroupContainingName(@Param("groupName") String groupName, - @Param("keyword") String keyword, @Param("uuidList") List uuidList); + @Param("keyword") String keyword, @Param("uuidList") List uuidList); @Query("select u from User u " - + "where u.group.groupName = :groupName " - + "and u.uuid not in :uuidList " - + "and LOWER(u.yelloId) like LOWER(CONCAT('%', :keyword, '%')) " - + "and u.deletedAt is null " - + "order by u.yelloId ASC ") + + "where u.group.groupName = :groupName " + + "and u.uuid not in :uuidList " + + "and LOWER(u.yelloId) like LOWER(CONCAT('%', :keyword, '%')) " + + "and u.deletedAt is null " + + "order by u.yelloId ASC ") List findAllByGroupContainingYelloId(@Param("groupName") String groupName, - @Param("keyword") String keyword, @Param("uuidList") List uuidList); + @Param("keyword") String keyword, @Param("uuidList") List uuidList); @Query("select u from User u " - + "where u.group.groupName <> :groupName " - + "and u.uuid not in :uuidList " - + "and LOWER(u.yelloId) like LOWER(CONCAT('%', :keyword, '%')) " - + "and u.deletedAt is null " - + "order by u.groupAdmissionYear DESC ") + + "where u.group.groupName <> :groupName " + + "and u.uuid not in :uuidList " + + "and LOWER(u.yelloId) like LOWER(CONCAT('%', :keyword, '%')) " + + "and u.deletedAt is null " + + "order by u.groupAdmissionYear DESC ") List findAllByOtherGroupContainingYelloId(@Param("groupName") String groupName, - @Param("keyword") String keyword, @Param("uuidList") List uuidList); + @Param("keyword") String keyword, @Param("uuidList") List uuidList); @Query("select u from User u " - + "where u.deviceToken = :deviceToken " - + "and u.deletedAt is null") + + "where u.deviceToken = :deviceToken " + + "and u.deletedAt is null") Optional findByDeviceToken(@Param("deviceToken") String deviceToken); @Query("select u from User u " + - "where u.deviceToken = :deviceToken") + "where u.deviceToken = :deviceToken") Optional findByDeviceTokenNotFiltered(@Param("deviceToken") String deviceToken); Long countAllByYelloIdContaining(String yelloId); Long countAllByNameContaining(String name); + Long countAllByGender(Gender gender); + @Query("select u from User u " - + "where LOWER(u.yelloId) like LOWER(CONCAT('%', :yelloId, '%'))") + + "where LOWER(u.yelloId) like LOWER(CONCAT('%', :yelloId, '%'))") Page findAllByYelloIdContaining(Pageable pageable, @Param("yelloId") String yelloId); @Query("select u from User u " - + "where LOWER(u.name) like LOWER(CONCAT('%', :name, '%'))") + + "where LOWER(u.name) like LOWER(CONCAT('%', :name, '%'))") Page findAllByNameContaining(Pageable pageable, @Param("name") String name); } diff --git a/src/main/java/com/yello/server/domain/user/repository/UserRepository.java b/src/main/java/com/yello/server/domain/user/repository/UserRepository.java index 0c9d9256..c7f21ee4 100644 --- a/src/main/java/com/yello/server/domain/user/repository/UserRepository.java +++ b/src/main/java/com/yello/server/domain/user/repository/UserRepository.java @@ -1,5 +1,6 @@ package com.yello.server.domain.user.repository; +import com.yello.server.domain.user.entity.Gender; import com.yello.server.domain.user.entity.User; import java.util.List; import java.util.Optional; @@ -60,7 +61,8 @@ List findAllByGroupContainingYelloId(String groupName, String keyword, List findAllByOtherGroupContainingYelloId(String groupName, String keyword, List uuidList); - List findAllByGroupNameContainingAndFriendListNotContaining(String keyword, List uuidList, List friendList); + List findAllByGroupNameContainingAndFriendListNotContaining(String keyword, List uuidList, + List friendList); Long count(); @@ -68,6 +70,8 @@ List findAllByOtherGroupContainingYelloId(String groupName, String keyword Long countAllByNameContaining(String name); + Long countAllByGender(Gender gender); + Page findAll(Pageable pageable); Page findAllByYelloIdContaining(Pageable pageable, String yelloId); diff --git a/src/main/java/com/yello/server/domain/user/repository/UserRepositoryImpl.java b/src/main/java/com/yello/server/domain/user/repository/UserRepositoryImpl.java index 2ac72a3d..e036f3c6 100644 --- a/src/main/java/com/yello/server/domain/user/repository/UserRepositoryImpl.java +++ b/src/main/java/com/yello/server/domain/user/repository/UserRepositoryImpl.java @@ -7,9 +7,8 @@ import static com.yello.server.global.common.ErrorCode.YELLOID_NOT_FOUND_USER_EXCEPTION; import com.querydsl.core.BooleanBuilder; -import com.querydsl.core.QueryMetadata; -import com.querydsl.core.types.Predicate; import com.querydsl.jpa.impl.JPAQueryFactory; +import com.yello.server.domain.user.entity.Gender; import com.yello.server.domain.user.entity.User; import com.yello.server.domain.user.exception.UserNotFoundException; import java.util.List; @@ -25,8 +24,8 @@ @Transactional(readOnly = true) public class UserRepositoryImpl implements UserRepository { - private final UserJpaRepository userJpaRepository; private final JPAQueryFactory jpaQueryFactory; + private final UserJpaRepository userJpaRepository; @Override public User save(User user) { @@ -160,13 +159,14 @@ public List findAllByOtherGroupContainingYelloId(String groupName, String } @Override - public List findAllByGroupNameContainingAndFriendListNotContaining(String keyword, List uuidList, List friendList) { + public List findAllByGroupNameContainingAndFriendListNotContaining(String keyword, List uuidList, + List friendList) { BooleanBuilder whereClause = new BooleanBuilder(); whereClause.and(user.group.groupName.like("%" + keyword + "%")); whereClause.and(user.uuid.notIn(uuidList)); whereClause.and(user.deletedAt.isNull()); - if(!friendList.isEmpty()) { + if (!friendList.isEmpty()) { whereClause.and(user.notIn(friendList)); } @@ -192,6 +192,11 @@ public Long countAllByNameContaining(String name) { return userJpaRepository.countAllByNameContaining(name); } + @Override + public Long countAllByGender(Gender gender) { + return userJpaRepository.countAllByGender(gender); + } + @Override public Page findAll(Pageable pageable) { return userJpaRepository.findAll(pageable); diff --git a/src/main/java/com/yello/server/global/common/entity/JsonConverter.java b/src/main/java/com/yello/server/global/common/entity/JsonConverter.java new file mode 100644 index 00000000..040e0e5a --- /dev/null +++ b/src/main/java/com/yello/server/global/common/entity/JsonConverter.java @@ -0,0 +1,43 @@ +package com.yello.server.global.common.entity; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import lombok.RequiredArgsConstructor; + +@Converter +@RequiredArgsConstructor +public class JsonConverter implements AttributeConverter { + + private final ObjectMapper objectMapper; + + @Override + public String convertToDatabaseColumn(T data) { + if (data == null) { + return null; + } + + try { + return objectMapper.writeValueAsString(data); + } catch (JsonProcessingException e) { + return null; + } + } + + @Override + public T convertToEntityAttribute(String database) { + if (database == null) { + return null; + } + + try { + return objectMapper.readValue(database, new TypeReference() { + }); + } catch (JsonProcessingException e) { + return null; + } + } + +} diff --git a/src/main/java/com/yello/server/global/common/util/RestUtil.java b/src/main/java/com/yello/server/global/common/util/RestUtil.java index 396cce04..8caf0b7b 100644 --- a/src/main/java/com/yello/server/global/common/util/RestUtil.java +++ b/src/main/java/com/yello/server/global/common/util/RestUtil.java @@ -8,12 +8,16 @@ import com.yello.server.domain.authorization.dto.kakao.KakaoTokenInfo; import com.yello.server.global.common.dto.response.GoogleInAppGetResponse; import com.yello.server.global.common.dto.response.GoogleTokenIssueResponse; +import jakarta.servlet.http.HttpServletRequest; +import java.io.BufferedReader; import java.io.IOException; +import java.io.InputStream; import java.io.InputStreamReader; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.ClassPathResource; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; +import org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestWrapper; import org.springframework.stereotype.Component; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; @@ -106,6 +110,41 @@ public static ResponseEntity getGoogleTicketCheck(String .block(); } + public static String getRequestBody(HttpServletRequest request) throws IOException { + if (!(request instanceof SecurityContextHolderAwareRequestWrapper)) { + throw new IllegalArgumentException("MultiReadHttpServletRequest 설정을 확인하세요"); + } + + String body = null; + StringBuilder stringBuilder = new StringBuilder(); + BufferedReader bufferedReader = null; + + try { + InputStream inputStream = request.getInputStream(); + if (inputStream != null) { + bufferedReader = new BufferedReader(new InputStreamReader(inputStream)); + char[] charBuffer = new char[128]; + int bytesRead = -1; + while ((bytesRead = bufferedReader.read(charBuffer)) > 0) { + stringBuilder.append(charBuffer, 0, bytesRead); + } + } + } catch (IOException ex) { + throw ex; + } finally { + if (bufferedReader != null) { + try { + bufferedReader.close(); + } catch (IOException ex) { + throw ex; + } + } + } + + body = stringBuilder.toString(); + return body; + } + @Value("${google.developer.key}") public void setGoogleClientSecretPath(String value) { GOOGLE_CLIENT_SECRET_PATH = value; diff --git a/src/main/java/com/yello/server/global/exception/ControllerExceptionAdvice.java b/src/main/java/com/yello/server/global/exception/ControllerExceptionAdvice.java index b42507fd..63819038 100644 --- a/src/main/java/com/yello/server/global/exception/ControllerExceptionAdvice.java +++ b/src/main/java/com/yello/server/global/exception/ControllerExceptionAdvice.java @@ -45,12 +45,10 @@ import com.yello.server.domain.vote.exception.VoteForbiddenException; import com.yello.server.domain.vote.exception.VoteNotFoundException; import com.yello.server.global.common.dto.BaseResponse; -import com.yello.server.infrastructure.slack.factory.SlackWebhookMessageFactory; +import com.yello.server.infrastructure.slack.dto.response.SlackChannel; +import com.yello.server.infrastructure.slack.service.SlackService; import jakarta.servlet.http.HttpServletRequest; -import net.gpedro.integrations.slack.SlackApi; -import net.gpedro.integrations.slack.SlackMessage; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.core.task.TaskExecutor; +import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageConversionException; import org.springframework.http.converter.HttpMessageNotReadableException; @@ -62,30 +60,14 @@ import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; @RestControllerAdvice +@RequiredArgsConstructor public class ControllerExceptionAdvice { - private final SlackApi slackErrorApi; - private final SlackWebhookMessageFactory slackWebhookMessageFactory; - private final TaskExecutor taskExecutor; - - public ControllerExceptionAdvice( - @Qualifier("slackErrorApi") SlackApi slackErrorApi, - SlackWebhookMessageFactory slackWebhookMessageFactory, - @Qualifier("threadPoolTaskExecutor") TaskExecutor taskExecutor - ) { - this.slackWebhookMessageFactory = slackWebhookMessageFactory; - this.taskExecutor = taskExecutor; - this.slackErrorApi = slackErrorApi; - } + private final SlackService slackService; @ExceptionHandler(Exception.class) void handleException(HttpServletRequest request, Exception exception) throws Exception { - SlackMessage slackMessage = slackWebhookMessageFactory.generateSlackErrorMessage( - request, - exception - ); - Runnable runnable = () -> slackErrorApi.call(slackMessage); - taskExecutor.execute(runnable); + slackService.sendErrorMessage(SlackChannel.ERROR, request, exception); throw exception; } diff --git a/src/main/java/com/yello/server/infrastructure/slack/annotation/SlackApplePurchaseNotification.java b/src/main/java/com/yello/server/infrastructure/slack/annotation/SlackApplePurchaseNotification.java deleted file mode 100644 index fcf4043b..00000000 --- a/src/main/java/com/yello/server/infrastructure/slack/annotation/SlackApplePurchaseNotification.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.yello.server.infrastructure.slack.annotation; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@Target(ElementType.METHOD) -@Retention(RetentionPolicy.RUNTIME) -public @interface SlackApplePurchaseNotification { - -} diff --git a/src/main/java/com/yello/server/infrastructure/slack/annotation/SlackPurchaseNotification.java b/src/main/java/com/yello/server/infrastructure/slack/annotation/SlackPurchaseNotification.java deleted file mode 100644 index 78ad5ac3..00000000 --- a/src/main/java/com/yello/server/infrastructure/slack/annotation/SlackPurchaseNotification.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.yello.server.infrastructure.slack.annotation; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@Target(ElementType.METHOD) -@Retention(RetentionPolicy.RUNTIME) -public @interface SlackPurchaseNotification { - -} diff --git a/src/main/java/com/yello/server/infrastructure/slack/annotation/SlackSignUpNotification.java b/src/main/java/com/yello/server/infrastructure/slack/annotation/SlackSignUpNotification.java deleted file mode 100644 index 65623dfb..00000000 --- a/src/main/java/com/yello/server/infrastructure/slack/annotation/SlackSignUpNotification.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.yello.server.infrastructure.slack.annotation; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@Target(ElementType.METHOD) -@Retention(RetentionPolicy.RUNTIME) -public @interface SlackSignUpNotification { - -} diff --git a/src/main/java/com/yello/server/infrastructure/slack/aspect/SlackApplePurchaseNotificationAspect.java b/src/main/java/com/yello/server/infrastructure/slack/aspect/SlackApplePurchaseNotificationAspect.java deleted file mode 100644 index 88d34c67..00000000 --- a/src/main/java/com/yello/server/infrastructure/slack/aspect/SlackApplePurchaseNotificationAspect.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.yello.server.infrastructure.slack.aspect; - -import com.yello.server.infrastructure.slack.factory.SlackWebhookMessageFactory; -import jakarta.servlet.http.HttpServletRequest; -import net.gpedro.integrations.slack.SlackApi; -import net.gpedro.integrations.slack.SlackMessage; -import org.aspectj.lang.ProceedingJoinPoint; -import org.aspectj.lang.annotation.Around; -import org.aspectj.lang.annotation.Aspect; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.core.task.TaskExecutor; -import org.springframework.stereotype.Component; -import org.springframework.web.context.request.RequestContextHolder; -import org.springframework.web.context.request.ServletRequestAttributes; - -@Aspect -@Component -public class SlackApplePurchaseNotificationAspect { - - private final SlackApi slackApplePurchaseApi; - private final SlackWebhookMessageFactory slackWebhookMessageFactory; - private final TaskExecutor taskExecutor; - - public SlackApplePurchaseNotificationAspect( - @Qualifier("slackApplePurchaseNotificationApi") SlackApi slackApplePurchaseApi, - SlackWebhookMessageFactory slackWebhookMessageFactory, - @Qualifier("threadPoolTaskExecutor") TaskExecutor taskExecutor) { - this.slackApplePurchaseApi = slackApplePurchaseApi; - this.slackWebhookMessageFactory = slackWebhookMessageFactory; - this.taskExecutor = taskExecutor; - } - - @Around("@annotation(com.yello.server.infrastructure.slack.annotation.SlackApplePurchaseNotification)") - Object applePurchaseNotification(ProceedingJoinPoint proceedingJoinPoint) throws Throwable { - HttpServletRequest request = - ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()) - .getRequest(); - - SlackMessage slackMessage = slackWebhookMessageFactory.generateAppleSlackPurchaseMessage( - request - ); - - Runnable runnable = () -> slackApplePurchaseApi.call(slackMessage); - taskExecutor.execute(runnable); - - return proceedingJoinPoint.proceed(); - } -} diff --git a/src/main/java/com/yello/server/infrastructure/slack/aspect/SlackPurchaseNotificationAspect.java b/src/main/java/com/yello/server/infrastructure/slack/aspect/SlackPurchaseNotificationAspect.java deleted file mode 100644 index 9586cccb..00000000 --- a/src/main/java/com/yello/server/infrastructure/slack/aspect/SlackPurchaseNotificationAspect.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.yello.server.infrastructure.slack.aspect; - -import com.yello.server.infrastructure.slack.factory.SlackWebhookMessageFactory; -import jakarta.servlet.http.HttpServletRequest; -import net.gpedro.integrations.slack.SlackApi; -import net.gpedro.integrations.slack.SlackMessage; -import org.aspectj.lang.ProceedingJoinPoint; -import org.aspectj.lang.annotation.Around; -import org.aspectj.lang.annotation.Aspect; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.core.task.TaskExecutor; -import org.springframework.stereotype.Component; -import org.springframework.web.context.request.RequestContextHolder; -import org.springframework.web.context.request.ServletRequestAttributes; - -@Aspect -@Component -public class SlackPurchaseNotificationAspect { - - private final SlackApi slackPurchaseApi; - private final SlackWebhookMessageFactory slackWebhookMessageFactory; - private final TaskExecutor taskExecutor; - - public SlackPurchaseNotificationAspect( - @Qualifier("slackPurchaseApi") SlackApi slackPurchaseApi, - SlackWebhookMessageFactory slackWebhookMessageFactory, - @Qualifier("threadPoolTaskExecutor") TaskExecutor taskExecutor) { - this.slackPurchaseApi = slackPurchaseApi; - this.slackWebhookMessageFactory = slackWebhookMessageFactory; - this.taskExecutor = taskExecutor; - } - - @Around("@annotation(com.yello.server.infrastructure.slack.annotation.SlackPurchaseNotification)") - Object purchaseNotification(ProceedingJoinPoint proceedingJoinPoint) throws Throwable { - HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()) - .getRequest(); - - SlackMessage slackMessage = slackWebhookMessageFactory.generateSlackPurchaseMessage( - request - ); - - Runnable runnable = () -> slackPurchaseApi.call(slackMessage); - taskExecutor.execute(runnable); - - return proceedingJoinPoint.proceed(); - } -} diff --git a/src/main/java/com/yello/server/infrastructure/slack/aspect/SlackSignUpNotificationAspect.java b/src/main/java/com/yello/server/infrastructure/slack/aspect/SlackSignUpNotificationAspect.java deleted file mode 100644 index e5af18da..00000000 --- a/src/main/java/com/yello/server/infrastructure/slack/aspect/SlackSignUpNotificationAspect.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.yello.server.infrastructure.slack.aspect; - -import com.yello.server.infrastructure.slack.factory.SlackWebhookMessageFactory; -import jakarta.servlet.http.HttpServletRequest; -import net.gpedro.integrations.slack.SlackApi; -import net.gpedro.integrations.slack.SlackMessage; -import org.aspectj.lang.ProceedingJoinPoint; -import org.aspectj.lang.annotation.Around; -import org.aspectj.lang.annotation.Aspect; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.core.task.TaskExecutor; -import org.springframework.stereotype.Component; -import org.springframework.web.context.request.RequestContextHolder; -import org.springframework.web.context.request.ServletRequestAttributes; - -@Aspect -@Component -public class SlackSignUpNotificationAspect { - - private final SlackApi slackSignUpApi; - private final SlackWebhookMessageFactory slackWebhookMessageFactory; - private final TaskExecutor taskExecutor; - - public SlackSignUpNotificationAspect( - @Qualifier("slackSignUpApi") SlackApi slackSignUpApi, - SlackWebhookMessageFactory slackWebhookMessageFactory, - @Qualifier("threadPoolTaskExecutor") TaskExecutor taskExecutor) { - this.slackSignUpApi = slackSignUpApi; - this.slackWebhookMessageFactory = slackWebhookMessageFactory; - this.taskExecutor = taskExecutor; - } - - @Around("@annotation(com.yello.server.infrastructure.slack.annotation.SlackSignUpNotification)") - Object signUpNotification(ProceedingJoinPoint proceedingJoinPoint) throws Throwable { - HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()) - .getRequest(); - - SlackMessage slackMessage = slackWebhookMessageFactory.generateSlackSignUpMessage( - request - ); - - Runnable runnable = () -> slackSignUpApi.call(slackMessage); - taskExecutor.execute(runnable); - - return proceedingJoinPoint.proceed(); - } -} diff --git a/src/main/java/com/yello/server/infrastructure/slack/configuration/SlackConfiguration.java b/src/main/java/com/yello/server/infrastructure/slack/configuration/SlackConfiguration.java index 744f2289..7788c38b 100644 --- a/src/main/java/com/yello/server/infrastructure/slack/configuration/SlackConfiguration.java +++ b/src/main/java/com/yello/server/infrastructure/slack/configuration/SlackConfiguration.java @@ -1,43 +1,46 @@ package com.yello.server.infrastructure.slack.configuration; -import net.gpedro.integrations.slack.SlackApi; +import com.yello.server.infrastructure.slack.dto.response.SlackChannel; +import jakarta.annotation.PostConstruct; import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class SlackConfiguration { + private static final String slackUrl = "https://hooks.slack.com/services"; @Value("${slack.token.ambulence}") - String slackTokenForError; - + private String slackTokenForError; @Value("${slack.token.bank}") - String slackTokenForPurchase; - + private String slackTokenForPurchase; @Value("${slack.token.sign-up}") - String slackTokenForSignUp; - + private String slackTokenForSignUp; @Value("${slack.token.apple-bank-alarm}") - String slackTokenForApplePurchaseNotification; - - @Bean - SlackApi slackErrorApi() { - return new SlackApi("https://hooks.slack.com/services/" + slackTokenForError); - } - - @Bean - SlackApi slackPurchaseApi() { - return new SlackApi("https://hooks.slack.com/services/" + slackTokenForPurchase); - } - - @Bean - SlackApi slackSignUpApi() { - return new SlackApi("https://hooks.slack.com/services/" + slackTokenForSignUp); - } - - @Bean - SlackApi slackApplePurchaseNotificationApi() { - return new SlackApi( - "https://hooks.slack.com/services/" + slackTokenForApplePurchaseNotification); + private String slackTokenForApplePurchaseNotification; + @Value("${slack.token.data-daily}") + private String slackTokenDataDaily; + + @PostConstruct + public void inject() { + for (SlackChannel channel : SlackChannel.values()) { + switch (channel) { + case ERROR -> { + channel.SLACK_WEBHOOK_URL(String.format("%s/%s", slackUrl, slackTokenForError)); + } + case PURCHASE -> { + channel.SLACK_WEBHOOK_URL(String.format("%s/%s", slackUrl, slackTokenForPurchase)); + } + case SIGN_UP -> { + channel.SLACK_WEBHOOK_URL(String.format("%s/%s", slackUrl, slackTokenForSignUp)); + } + case APPLE_PURCHASE_NOTIFICATION -> { + channel.SLACK_WEBHOOK_URL(String.format("%s/%s", slackUrl, slackTokenForApplePurchaseNotification)); + } + case DATA_DAILY -> { + channel.SLACK_WEBHOOK_URL(String.format("%s/%s", slackUrl, slackTokenDataDaily)); + } + default -> throw new IllegalStateException("Unexpected value: " + this); + } + } } } diff --git a/src/main/java/com/yello/server/infrastructure/slack/dto/response/SlackChannel.java b/src/main/java/com/yello/server/infrastructure/slack/dto/response/SlackChannel.java new file mode 100644 index 00000000..9fd24807 --- /dev/null +++ b/src/main/java/com/yello/server/infrastructure/slack/dto/response/SlackChannel.java @@ -0,0 +1,23 @@ +package com.yello.server.infrastructure.slack.dto.response; + +/** + * @see com.yello.server.infrastructure.slack.configuration.SlackConfiguration + */ +public enum SlackChannel { + ERROR, + PURCHASE, + SIGN_UP, + @Deprecated(since = "2.0.3v") + APPLE_PURCHASE_NOTIFICATION, + DATA_DAILY; + + private String SLACK_WEBHOOK_URL; + + public String SLACK_WEBHOOK_URL() { + return SLACK_WEBHOOK_URL; + } + + public void SLACK_WEBHOOK_URL(String SLACK_WEBHOOK_URL) { + this.SLACK_WEBHOOK_URL = SLACK_WEBHOOK_URL; + } +} diff --git a/src/main/java/com/yello/server/infrastructure/slack/factory/SlackWebhookMessageFactory.java b/src/main/java/com/yello/server/infrastructure/slack/factory/SlackWebhookMessageFactory.java index 82b19f0c..79e0eba8 100644 --- a/src/main/java/com/yello/server/infrastructure/slack/factory/SlackWebhookMessageFactory.java +++ b/src/main/java/com/yello/server/infrastructure/slack/factory/SlackWebhookMessageFactory.java @@ -1,230 +1,502 @@ package com.yello.server.infrastructure.slack.factory; +import static com.yello.server.global.common.util.RestUtil.getRequestBody; + import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.yello.server.domain.authorization.service.TokenProvider; +import com.slack.api.model.Attachment; +import com.slack.api.model.block.ActionsBlock; +import com.slack.api.model.block.DividerBlock; +import com.slack.api.model.block.HeaderBlock; +import com.slack.api.model.block.SectionBlock; +import com.slack.api.model.block.UnknownBlock; +import com.slack.api.model.block.UnknownBlockElement; +import com.slack.api.model.block.composition.MarkdownTextObject; +import com.slack.api.model.block.composition.PlainTextObject; +import com.slack.api.model.block.element.ButtonElement; +import com.slack.api.model.block.element.ImageElement; +import com.slack.api.webhook.Payload; +import com.yello.server.domain.authorization.dto.request.SignUpRequest; +import com.yello.server.domain.group.entity.UserGroup; +import com.yello.server.domain.group.repository.UserGroupRepository; +import com.yello.server.domain.purchase.dto.request.google.DeveloperNotification; +import com.yello.server.domain.purchase.entity.ProductType; +import com.yello.server.domain.purchase.entity.Purchase; import com.yello.server.domain.purchase.service.PurchaseManager; import com.yello.server.domain.user.entity.User; import com.yello.server.domain.user.repository.UserRepository; import com.yello.server.global.common.factory.TimeFactory; +import com.yello.server.global.common.util.ConstantUtil; import com.yello.server.infrastructure.slack.dto.response.SlackAppleNotificationResponse; import jakarta.servlet.http.HttpServletRequest; -import java.io.BufferedReader; import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.time.LocalDateTime; +import java.net.URI; +import java.net.URISyntaxException; +import java.time.ZonedDateTime; import java.util.Arrays; -import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.Objects; import java.util.Optional; import lombok.RequiredArgsConstructor; -import net.gpedro.integrations.slack.SlackAttachment; -import net.gpedro.integrations.slack.SlackField; -import net.gpedro.integrations.slack.SlackMessage; -import org.springframework.http.HttpHeaders; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @Component @RequiredArgsConstructor public class SlackWebhookMessageFactory { - private static final String ERROR_TITLE = "긴급 환자가 이송되었습니다"; - private static final String ERROR_USERNAME = "옐로 소방서"; - private static final String PURCHASE_TITLE = "돈이 입금되었습니다."; - private static final String PURCHASE_USERNAME = "옐로 뱅크"; + /** + * Error + */ + private static final String ERROR_TITLE = "500에러가 발생하였습니다."; + private static final String ERROR_SECTION_0_TEXT = "• *Exception Class*: %s\n• *Exception Message*: %s"; + private static final String ERROR_BUTTON_0_TITLE = "통신 내역 자세히 보기"; + + /** + * Purchase + */ + private static final String PURCHASE_TITLE = "결제가 체결되었습니다."; + private static final String PURCHASE_SECTION_0_TEXT = "• *이름*: %s\n• *성별*: %s\n• *yelloId*: %s\n• *그룹*: %s"; + private static final String PURCHASE_IMAGE_ALT = "profile_image"; + private static final String PURCHASE_SECTION_1_TEXT = "• *결제 종류*: %s\n• *플랫폼*: %s\n• *금액*: %s"; + private static final String PURCHASE_BUTTON_0_TITLE = "유저 정보 확인하기"; + private static final String PURCHASE_BUTTON_1_TITLE = "통신 내역 자세히 보기"; + + /** + * SignUp + */ + private static final String SIGNUP_TITLE = "신규 회원이 회원가입하였습니다."; + private static final String SIGNUP_SECTION_TEXT = "• *이름*: %s\n• *성별*: %s\n• *yelloId*: %s\n• *그룹*: %s\n• *친구추가*: %s명\n• *추천인*: %s"; + private static final String SIGNUP_IMAGE_ALT = "profile_image"; + private static final String SIGNUP_BUTTON_0_TITLE = "유저 정보 확인하기"; + private static final String SIGNUP_BUTTON_1_TITLE = "통신 내역 자세히 보기"; - private static final String SIGNUP_TITLE = "신규 유저가 가입하였습니다. "; - private static final String SIGNUP_USERNAME = "옐로 온보딩"; + /** + * Apple State + */ + private static final String APPLE_STATE_TITLE = "결제 상태가 변경되었습니다."; + private static final String APPLE_STATE_SECTION_0_TEXT = "• *이름*: %s\n• *성별*: %s\n• *yelloId*: %s\n• *그룹*: %s"; + private static final String APPLE_STATE_IMAGE_ALT = "profile_image"; + private static final String APPLE_STATE_SECTION_1_TEXT = "• *영수증 ID*: %s\n• *notificationType*: %s\n• *subtype*: %s\n> %s"; + private static final String APPLE_STATE_BUTTON_0_TITLE = "유저 정보 확인하기"; + private static final String APPLE_STATE_BUTTON_1_TITLE = "통신 내역 자세히 보기"; + private static final String APPLE_STATE_BUTTON_2_TITLE = "관련 문서보기"; + private static final String APPLE_STATE_BUTTON_2_LINK = "https://developer.apple.com/documentation/appstoreservernotifications/responsebodyv2decodedpayload"; - private static final String APPLE_PURCHASE_ALARM_TITLE = "애플이 결제 관련 알림을 보냈습니다."; - private static final String APPLE_PURCHASE_ALARM_USERNAME = "애플 뱅크 알림"; + /** + * Google State + */ + private static final String GOOGLE_STATE_TITLE = "결제 상태가 변경되었습니다."; + private static final String GOOGLE_STATE_SECTION_0_TEXT = "• *이름*: %s\n• *성별*: %s\n• *yelloId*: %s\n• *그룹*: %s"; + private static final String GOOGLE_STATE_IMAGE_ALT = "profile_image"; + private static final String GOOGLE_STATE_SECTION_1_TEXT = "• *영수증 ID*: %s\n• *결제 종류*: %s\n• *Type*: %s\n> %s"; + private static final String GOOGLE_STATE_BUTTON_0_TITLE = "유저 정보 확인하기"; + private static final String GOOGLE_STATE_BUTTON_1_TITLE = "통신 내역 자세히 보기"; + private static final String GOOGLE_STATE_BUTTON_2_TITLE = "관련 문서보기"; + private static final String GOOGLE_STATE_BUTTON_2_LINK = "https://developer.android.com/google/play/billing/rtdn-reference"; + + /** + * Unicode + */ + private static final String lineBreak = "%0A"; + private static final String space = "%20"; + private static final String doubleQuote = "%22"; + private static final String singleQuote = "%27"; + private static final String lparen = "%7B"; + private static final String rparen = "%7D"; + private static final String lbracket = "%5B"; + private static final String rbracket = "%5D"; + + private final ObjectMapper objectMapper; private final PurchaseManager purchaseManager; - private final TokenProvider tokenProvider; + private final UserGroupRepository userGroupRepository; private final UserRepository userRepository; - private static String getRequestBody(HttpServletRequest request) throws IOException { - - String body = null; - StringBuilder stringBuilder = new StringBuilder(); - BufferedReader bufferedReader = null; + @Value("${web.url.yelloworld}") + private String yelloworldUrl; + public com.slack.api.webhook.Payload generateErrorPayload(HttpServletRequest request, Exception exception) + throws IOException { + URI button0URI; try { - InputStream inputStream = request.getInputStream(); - if (inputStream != null) { - bufferedReader = new BufferedReader(new InputStreamReader(inputStream)); - char[] charBuffer = new char[128]; - int bytesRead = -1; - while ((bytesRead = bufferedReader.read(charBuffer)) > 0) { - stringBuilder.append(charBuffer, 0, bytesRead); - } - } - } catch (IOException ex) { - throw ex; - } finally { - if (bufferedReader != null) { - try { - bufferedReader.close(); - } catch (IOException ex) { - throw ex; - } - } + button0URI = generateRequestUri(String.format("%s%s", yelloworldUrl, "/admin/log/http"), request); + } catch (URISyntaxException e) { + throw new IllegalArgumentException(e); } - body = stringBuilder.toString(); - return body; + return Payload.builder() + .attachments(List.of( + Attachment.builder() + .text(Arrays.toString(exception.getStackTrace())) + .build() + )) + .blocks(List.of( + HeaderBlock.builder() + .text(PlainTextObject.builder() + .text(ERROR_TITLE) + .build()) + .build(), + DividerBlock.builder().build(), + SectionBlock.builder() + .text(MarkdownTextObject.builder() + .text(String.format(ERROR_SECTION_0_TEXT, + exception.getClass().getName(), + exception.getMessage() + )) + .build()) + .build(), + DividerBlock.builder().build(), + ActionsBlock.builder() + .elements(List.of( + ButtonElement.builder() + .text(PlainTextObject.builder() + .text(ERROR_BUTTON_0_TITLE) + .build()) + .url(button0URI.toASCIIString()) + .build() + )) + .build() + )) + .build(); } - public SlackMessage generateSlackErrorMessage( - HttpServletRequest request, - Exception exception - ) throws IOException { - return new SlackMessage() - .setAttachments(generateSlackErrorAttachment(request, exception)) - .setText(ERROR_TITLE) - .setUsername(ERROR_USERNAME); - } + public com.slack.api.webhook.Payload generatePurchasePayload(HttpServletRequest request, Purchase purchase) + throws IOException { + final User user = purchase.getUser(); - public SlackMessage generateSlackPurchaseMessage( - HttpServletRequest request - ) throws IOException { - return new SlackMessage() - .setAttachments(generateSlackPurchaseAttachment(request)) - .setText(PURCHASE_TITLE) - .setUsername(PURCHASE_USERNAME); - } + URI button0URI; + URI button1URI; + try { + button0URI = new URI(String.format("%s%s%s", yelloworldUrl, "/admin/user/", user.getId())); + button1URI = generateRequestUri(String.format("%s%s", yelloworldUrl, "/admin/log/http"), request); + } catch (URISyntaxException e) { + throw new IllegalArgumentException(e); + } - public SlackMessage generateSlackSignUpMessage( - HttpServletRequest request - ) throws IOException { - return new SlackMessage() - .setAttachments(generateSlackSignUpAttachment(request)) - .setText(SIGNUP_TITLE) - .setUsername(SIGNUP_USERNAME); + return Payload.builder() + .blocks(List.of( + HeaderBlock.builder() + .text(PlainTextObject.builder() + .text(PURCHASE_TITLE) + .build()) + .build(), + SectionBlock.builder() + .text(MarkdownTextObject.builder() + .text(String.format(PURCHASE_SECTION_0_TEXT, + user.getName(), + user.getGender(), + user.getYelloId(), + user.getGroup().toString() + )) + .build()) + .accessory(ImageElement.builder() + .imageUrl(user.getProfileImage()) + .altText(PURCHASE_IMAGE_ALT) + .build()) + .build(), + DividerBlock.builder().build(), + SectionBlock.builder() + .text(MarkdownTextObject.builder() + .text(String.format(PURCHASE_SECTION_1_TEXT, + purchase.getProductType(), + purchase.getGateway(), + purchase.getPrice() + )) + .build()) + .build(), + DividerBlock.builder().build(), + ActionsBlock.builder() + .elements(List.of( + ButtonElement.builder() + .text(PlainTextObject.builder() + .text(PURCHASE_BUTTON_0_TITLE) + .build()) + .url(button0URI.toASCIIString()) + .build(), + ButtonElement.builder() + .text(PlainTextObject.builder() + .text(PURCHASE_BUTTON_1_TITLE) + .build()) + .url(button1URI.toASCIIString()) + .build() + )) + .build() + )) + .build(); } - public SlackMessage generateAppleSlackPurchaseMessage( - HttpServletRequest request - ) throws IOException { - return new SlackMessage() - .setAttachments(generateSlackApplePurchaseAttachment(request)) - .setText(APPLE_PURCHASE_ALARM_TITLE) - .setUsername(APPLE_PURCHASE_ALARM_USERNAME); - } + public com.slack.api.webhook.Payload generateSignUpPayload(HttpServletRequest request) throws IOException { + final String requestBody = getRequestBody(request); + final SignUpRequest signUpRequest = objectMapper.readValue(requestBody, SignUpRequest.class); + final UserGroup userGroup = userGroupRepository.getById(signUpRequest.groupId()); + final User user = userRepository.getByYelloId(signUpRequest.yelloId()); - private List generateSlackErrorAttachment( - HttpServletRequest request, - Exception exception - ) throws IOException { - final SlackAttachment slackAttachment = new SlackAttachment() - .setFallback("Error") - .setColor("danger") - .setTitle(ERROR_TITLE) - .setTitleLink(request.getContextPath()) - .setText("Exception Class : " + exception.getClass().getName() + "\n" - + "Exception Message : " + exception.getMessage() + "\n" - + Arrays.toString(exception.getStackTrace())) - .setColor("danger") - .setFields(generateSlackFieldList(request)); - return Collections.singletonList(slackAttachment); - } + URI button0URI; + URI button1URI; + try { + button0URI = new URI(String.format("%s%s%s", yelloworldUrl, "/admin/user/", user.getId())); + button1URI = generateRequestUri(String.format("%s%s", yelloworldUrl, "/admin/log/http"), request); + } catch (URISyntaxException e) { + throw new IllegalArgumentException(e); + } - private List generateSlackPurchaseAttachment( - HttpServletRequest request - ) throws IOException { - final SlackAttachment slackAttachment = new SlackAttachment() - .setFallback("good") - .setColor("good") - .setTitle(PURCHASE_TITLE) - .setTitleLink(request.getContextPath()) - .setText(PURCHASE_TITLE) - .setFields(generateSlackFieldList(request)); - return Collections.singletonList(slackAttachment); + return Payload.builder() + .blocks(List.of( + HeaderBlock.builder() + .text(PlainTextObject.builder() + .text(SIGNUP_TITLE) + .build()) + .build(), + SectionBlock.builder() + .text(MarkdownTextObject.builder() + .text(String.format(SIGNUP_SECTION_TEXT, + signUpRequest.name(), + signUpRequest.gender(), + signUpRequest.yelloId(), + userGroup.toString(), + signUpRequest.friends().size(), + signUpRequest.recommendId() + )) + .build()) + .accessory(ImageElement.builder() + .imageUrl(signUpRequest.profileImage()) + .altText(SIGNUP_IMAGE_ALT) + .build()) + .build(), + DividerBlock.builder().build(), + ActionsBlock.builder() + .elements(List.of( + ButtonElement.builder() + .text(PlainTextObject.builder() + .text(SIGNUP_BUTTON_0_TITLE) + .build()) + .url(button0URI.toASCIIString()) + .build(), + ButtonElement.builder() + .text(PlainTextObject.builder() + .text(SIGNUP_BUTTON_1_TITLE) + .build()) + .url(button1URI.toASCIIString()) + .build() + )) + .build() + )) + .build(); } - private List generateSlackSignUpAttachment( - HttpServletRequest request - ) throws IOException { - final SlackAttachment slackAttachment = new SlackAttachment() - .setFallback("good") - .setColor("good") - .setTitle(SIGNUP_TITLE) - .setTitleLink(request.getContextPath()) - .setText(SIGNUP_TITLE) - .setFields(generateSlackSimpleFieldList(request)); - return Collections.singletonList(slackAttachment); - } + public com.slack.api.webhook.Payload generateAppleStateChangedMessage(HttpServletRequest request) + throws IOException, URISyntaxException { + final SlackAppleNotificationResponse notificationResponse = decodeAppleNotificationRequestField( + request); + final User user = userRepository.getById(notificationResponse.userId()); - private List generateSlackApplePurchaseAttachment( - HttpServletRequest request - ) throws IOException { - final SlackAttachment slackAttachment = new SlackAttachment() - .setFallback("good") - .setColor("good") - .setTitle(APPLE_PURCHASE_ALARM_TITLE) - .setTitleLink(request.getContextPath()) - .setText(APPLE_PURCHASE_ALARM_TITLE) - .setFields(generateSlackAppleFieldList(request)); - return Collections.singletonList(slackAttachment); - } + URI button0URI; + URI button1URI; + final URI button2URI = new URI(APPLE_STATE_BUTTON_2_LINK); + try { + button0URI = new URI(String.format("%s%s%s", yelloworldUrl, "/admin/user/", user.getId())); + button1URI = generateRequestUri(String.format("%s%s", yelloworldUrl, "/admin/log/http"), request); + } catch (URISyntaxException e) { + throw new IllegalArgumentException(e); + } - private List generateSlackFieldList( - HttpServletRequest request - ) throws IOException { - final String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); - final String token = authHeader == null ? "null" : authHeader.substring("Bearer ".length()); - final Long userId = authHeader == null ? -1L : tokenProvider.getUserId(token); - final Optional user = - authHeader == null ? Optional.empty() : userRepository.findById(userId); - final String yelloId = user.isPresent() ? user.get().getYelloId() : "null"; - final String deviceToken = user.isPresent() ? user.get().getDeviceToken() : "null"; - - String userInfo = - String.format("userId : %d %nyelloId : %s %ndeviceToken : %s", userId, yelloId, - deviceToken); - - return Arrays.asList( - new SlackField().setTitle("Request Method").setValue(request.getMethod()), - new SlackField().setTitle("Request URL").setValue(request.getRequestURL().toString()), - new SlackField().setTitle("Request Time") - .setValue(TimeFactory.toDateFormattedString(LocalDateTime.now())), - new SlackField().setTitle("Request IP").setValue(request.getRemoteAddr()), - new SlackField().setTitle("Request Headers") - .setValue(request.toString()), - new SlackField().setTitle("Request Body") - .setValue(getRequestBody(request)), - new SlackField().setTitle("인증/인가 정보 - Authorization") - .setValue(request.getHeader(HttpHeaders.AUTHORIZATION)), - new SlackField().setTitle("인증/인가 정보 - 유저").setValue(userInfo) - ); + return Payload.builder() + .blocks(List.of( + HeaderBlock.builder() + .text(PlainTextObject.builder() + .text(APPLE_STATE_TITLE) + .build()) + .build(), + SectionBlock.builder() + .text(MarkdownTextObject.builder() + .text(String.format(APPLE_STATE_SECTION_0_TEXT, + user.getName(), + user.getGender(), + user.getYelloId(), + user.getGroup().toString() + )) + .build()) + .accessory(ImageElement.builder() + .imageUrl(user.getProfileImage()) + .altText(APPLE_STATE_IMAGE_ALT) + .build()) + .build(), + DividerBlock.builder().build(), + SectionBlock.builder() + .text(MarkdownTextObject.builder() + .text(String.format(APPLE_STATE_SECTION_1_TEXT, + notificationResponse.transactionId(), + notificationResponse.notificationType(), + notificationResponse.subtype(), + getAppleStateLabel(notificationResponse.notificationType(), notificationResponse.subtype()) + )) + .build()) + .build(), + DividerBlock.builder().build(), + ActionsBlock.builder() + .elements(List.of( + ButtonElement.builder() + .text(PlainTextObject.builder() + .text(APPLE_STATE_BUTTON_0_TITLE) + .build()) + .url(button0URI.toASCIIString()) + .build(), + ButtonElement.builder() + .text(PlainTextObject.builder() + .text(APPLE_STATE_BUTTON_1_TITLE) + .build()) + .url(button1URI.toASCIIString()) + .build(), + ButtonElement.builder() + .text(PlainTextObject.builder() + .text(APPLE_STATE_BUTTON_2_TITLE) + .build()) + .url(button2URI.toASCIIString()) + .build() + )) + .build() + )) + .build(); } - private List generateSlackSimpleFieldList( - HttpServletRequest request - ) throws IOException { - return Arrays.asList( - new SlackField().setTitle("Request Body") - .setValue(getRequestBody(request)) - ); + public com.slack.api.webhook.Payload generateGoogleStateChangedMessage(HttpServletRequest request, + Map.Entry> notification) + throws IOException, URISyntaxException { + final DeveloperNotification developerNotification = notification.getKey(); + final ProductType productType = developerNotification.getProductType(); + final Optional purchase = notification.getValue(); + + URI button0URI; + URI button1URI; + final URI button2URI = new URI(GOOGLE_STATE_BUTTON_2_LINK); + try { + button0URI = productType == ProductType.TEST + ? new URI(yelloworldUrl) + : new URI(String.format("%s%s%s", yelloworldUrl, "/admin/user/", purchase.get().getUser().getId())); + button1URI = generateRequestUri(String.format("%s%s", yelloworldUrl, "/admin/log/http"), request); + } catch (URISyntaxException e) { + throw new IllegalArgumentException(e); + } + + return Payload.builder() + .blocks(List.of( + HeaderBlock.builder() + .text(PlainTextObject.builder() + .text(GOOGLE_STATE_TITLE) + .build()) + .build(), + ( + productType == ProductType.TEST ? + UnknownBlock.builder().build() : + SectionBlock.builder() + .text(MarkdownTextObject.builder() + .text(String.format(GOOGLE_STATE_SECTION_0_TEXT, + purchase.get().getUser().getName(), + purchase.get().getUser().getGender(), + purchase.get().getUser().getYelloId(), + purchase.get().getUser().getGroup().toString() + )) + .build()) + .accessory(ImageElement.builder() + .imageUrl(purchase.get().getUser().getProfileImage()) + .altText(GOOGLE_STATE_IMAGE_ALT) + .build()) + .build() + ), + ( + productType == ProductType.TEST ? + UnknownBlock.builder().build() : + DividerBlock.builder().build() + ), + SectionBlock.builder() + .text(MarkdownTextObject.builder() + .text(String.format(GOOGLE_STATE_SECTION_1_TEXT, + purchase.map(Purchase::getTransactionId).orElse(null), + productType, + getGoogleStateTypeLabel(developerNotification), + getGoogleStateLabel(developerNotification) + )) + .build()) + .build(), + DividerBlock.builder().build(), + ActionsBlock.builder() + .elements(List.of( + ( + productType == ProductType.TEST ? + UnknownBlockElement.builder().build() : + ButtonElement.builder() + .text(PlainTextObject.builder() + .text(GOOGLE_STATE_BUTTON_0_TITLE) + .build()) + .url(button0URI.toASCIIString()) + .build() + ), + ButtonElement.builder() + .text(PlainTextObject.builder() + .text(GOOGLE_STATE_BUTTON_1_TITLE) + .build()) + .url(button1URI.toASCIIString()) + .build(), + ButtonElement.builder() + .text(PlainTextObject.builder() + .text(GOOGLE_STATE_BUTTON_2_TITLE) + .build()) + .url(button2URI.toASCIIString()) + .build() + )) + .build() + )) + .build(); } - private List generateSlackAppleFieldList( - HttpServletRequest request - ) throws IOException { - return Arrays.asList( - new SlackField().setTitle("Request Method").setValue(request.getMethod()), - new SlackField().setTitle("Request URL").setValue(request.getRequestURL().toString()), - new SlackField().setTitle("Request Time") - .setValue(TimeFactory.toDateFormattedString(LocalDateTime.now())), - new SlackField().setTitle("Request IP").setValue(request.getRemoteAddr()), - new SlackField().setTitle("Request Headers") - .setValue(request.toString()), - new SlackField().setTitle("Request Body") - .setValue(generateSlackAppleNotificationRequestField(request)) + private URI generateRequestUri(String link, HttpServletRequest request) throws IOException, URISyntaxException { + // Headers + StringBuilder headersBuilder = new StringBuilder(); + request.getHeaderNames().asIterator() + .forEachRemaining(headerName -> headersBuilder + .append(headerName) + .append(": ") + .append(request.getHeader(headerName)) + .append("\n") + ); + + Map button1Parameters = new HashMap<>(); + button1Parameters.put("method", request.getMethod()); + button1Parameters.put("url", request.getRequestURL().toString()); + button1Parameters.put("time", + TimeFactory.toDateFormattedString(ZonedDateTime.now(ConstantUtil.GlobalZoneId).toLocalDateTime())); + button1Parameters.put("ip", request.getRemoteAddr()); + button1Parameters.put("headers", headersBuilder.toString()); + button1Parameters.put("request_body", getRequestBody(request)); + + StringBuilder stringBuilder = new StringBuilder(link); + + // Queries + stringBuilder.append("?"); + for (Map.Entry entry : button1Parameters.entrySet()) { + stringBuilder.append(entry.getKey()); + stringBuilder.append("="); + stringBuilder.append(entry.getValue()); + stringBuilder.append("&"); + } + stringBuilder.deleteCharAt(stringBuilder.length() - 1); + + // replace to unicode + return new URI(stringBuilder.toString() + .replace(" ", space) + .replace("\n", lineBreak) + .replace("{", lparen) + .replace("}", rparen) + .replace("[", lbracket) + .replace("]", rbracket) + .replace("'", singleQuote) + .replace("\"", doubleQuote) ); } - private String generateSlackAppleNotificationRequestField( + private SlackAppleNotificationResponse decodeAppleNotificationRequestField( HttpServletRequest request ) throws IOException { String jsonString = getRequestBody(request); @@ -241,9 +513,152 @@ private String generateSlackAppleNotificationRequestField( e.printStackTrace(); } - SlackAppleNotificationResponse response = - purchaseManager.checkPurchaseDataByAppleSignedPayload(payload[0]); + return purchaseManager.checkPurchaseDataByAppleSignedPayload(payload[0]); + } - return response.toString(); + private String getGoogleStateTypeLabel(DeveloperNotification developerNotification) { + switch (Objects.requireNonNull(developerNotification.getProductType())) { + case TEST -> { + return ProductType.TEST.name(); + } + case YELLO_PLUS -> { + return developerNotification.subscriptionNotification().notificationType().name(); + } + case ONE_TICKET, TWO_TICKET, FIVE_TICKET -> { + return developerNotification.oneTimeProductNotification().notificationType().name(); + } + default -> { + return ""; + } + } + } + + private String getGoogleStateLabel(DeveloperNotification developerNotification) { + switch (Objects.requireNonNull(developerNotification.getProductType())) { + case TEST -> { + return ProductType.TEST.name(); + } + case YELLO_PLUS -> { + return switch (developerNotification.subscriptionNotification().notificationType()) { + case SUBSCRIPTION_RECOVERED -> "정기 결제 복구"; + case SUBSCRIPTION_RENEWED -> "정기 결제 갱신"; + case SUBSCRIPTION_CANCELED -> "정기 결제 취소"; + case SUBSCRIPTION_PURCHASED -> "첫 정기 결제"; + case SUBSCRIPTION_ON_HOLD -> "정기 결제 보류"; + case SUBSCRIPTION_IN_GRACE_PERIOD -> "정기 결제 유예"; + case SUBSCRIPTION_RESTARTED -> "정기 결제 복원"; + case SUBSCRIPTION_PRICE_CHANGE_CONFIRMED -> "가격 변경 확인 완료"; + case SUBSCRIPTION_DEFERRED -> "구독 갱신 연장"; + case SUBSCRIPTION_PAUSED -> "구독 일시중지"; + case SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED -> "구독 일시중지 일정 변경"; + case SUBSCRIPTION_REVOKED -> "만료 시간 전, 정기 결제 취소"; + case SUBSCRIPTION_EXPIRED -> "정기 결제 만료"; + }; + } + case ONE_TICKET, TWO_TICKET, FIVE_TICKET -> { + return switch (developerNotification.oneTimeProductNotification().notificationType()) { + case ONE_TIME_PRODUCT_PURCHASED -> "일회성 제품 구매"; + case ONE_TIME_PRODUCT_CANCELED -> "일회성 제품 구매 취소"; + }; + } + default -> { + return ""; + } + } + } + + private String getAppleStateLabel(String notificationType, String subType) { + switch (notificationType) { + case "SUBSCRIBED" -> { + return switch (subType) { + case "INITIAL_BUY" -> "그룹 첫 구독"; + case "RESUBSCRIBE" -> "그룹 재 구독"; + + default -> ""; + }; + } + case "DID_RENEW" -> { + return switch (subType) { + case "" -> "구독 갱신"; + case "BILLING_RECOVERY" -> "청구 재시도를 통해 구독 갱신"; + default -> ""; + }; + } + case "DID_CHANGE_RENEWAL_PREF" -> { + return switch (subType) { + case "DOWNGRADE" -> "그룹 구독 다운그레이드 필요"; + case "UPGRADE" -> "그룹 구독 업그레이드"; + case "" -> "그룹 구독 다운그레이드 취소"; + default -> ""; + }; + } + case "DID_CHANGE_RENEWAL_STATUS" -> { + return switch (subType) { + case "AUTO_RENEW_DISABLED" -> "구독 취소 또는 환불"; + case "AUTO_RENEW_ENABLED" -> "재구독 및 자동 갱신 활성화"; + case "" -> "가격 인상 고지 후 구독 취소"; + default -> ""; + }; + } + case "OFFER_REDEEMED" -> { + return switch (subType) { + case "" -> "프로모션/쿠폰 코드 사용"; + case "INITIAL_BUY" -> "코드 사용하여 첫 구독"; + case "RESUBSCRIBE" -> "코드 사용하여 재 구독"; + case "UPGRADE" -> "코드 사용하여 업그레이드"; + case "DOWNGRADE" -> "코드 사용하여 다운그레이드"; + default -> ""; + }; + } + case "EXPIRED" -> { + return switch (subType) { + case "VOLUNTARY", "BILLING_RETRY" -> "구독 만료"; + case "PRODUCT_NOT_FOR_SALE" -> "판매자가 판매를 중지하여 구독 만료"; + case "PRICE_INCREASE" -> "가격 인상 비동의로 구독 만료"; + default -> ""; + }; + } + case "DID_FAIL_TO_RENEW" -> { + return switch (subType) { + case "", "GRACE_PERIOD" -> "갱신이 되지 않아 재시도 시간에 다시시도"; + default -> ""; + }; + } + case "GRACE_PERIOD_EXPIRED" -> { + return "구독이 청구 유예 기간을 종료하고 청구 재시도를 계속합니다."; + } + case "PRICE_INCREASE" -> { + return switch (subType) { + case "PENDING" -> "가격 인상 고지 확인 대기 중"; + case "ACCEPTED" -> "가격 인상 동의"; + default -> ""; + }; + } + case "REFUND" -> { + return "거래 환불"; + } + case "REFUND_REVERSED" -> { + return "환불 취소"; + } + case "REFUND_DECLINED" -> { + return "환불 거부"; + } + case "CONSUMPTION_REQUEST" -> { + return "환불 정보 요청"; + } + case "REVOKE" -> { + return "가족 내 엑세스 없음"; + } + case "RENEWAL_EXTENDED" -> { + return switch (subType) { + case "", "SUMMARY" -> "구독 갱신 날짜 연장 성공"; + case "FAILURE" -> "구독 갱신 날짜 연장 실패"; + default -> ""; + }; + } + default -> { + return ""; + } + } } } diff --git a/src/main/java/com/yello/server/infrastructure/slack/service/SlackService.java b/src/main/java/com/yello/server/infrastructure/slack/service/SlackService.java new file mode 100644 index 00000000..c27dea86 --- /dev/null +++ b/src/main/java/com/yello/server/infrastructure/slack/service/SlackService.java @@ -0,0 +1,62 @@ +package com.yello.server.infrastructure.slack.service; + +import com.slack.api.Slack; +import com.slack.api.webhook.WebhookResponse; +import com.yello.server.domain.purchase.dto.request.google.DeveloperNotification; +import com.yello.server.domain.purchase.entity.Purchase; +import com.yello.server.infrastructure.slack.dto.response.SlackChannel; +import com.yello.server.infrastructure.slack.factory.SlackWebhookMessageFactory; +import jakarta.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.net.URISyntaxException; +import java.util.Map; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class SlackService { + + private final SlackWebhookMessageFactory messageFactory; + private final Slack slack = Slack.getInstance(); + + @Async + public void sendErrorMessage(SlackChannel slackChannel, HttpServletRequest request, Exception exception) + throws IOException { + send(slackChannel, messageFactory.generateErrorPayload(request, exception)); + } + + @Async + public void sendAppleStateChangedMessage(SlackChannel slackChannel, HttpServletRequest request) + throws IOException, URISyntaxException { + send(slackChannel, messageFactory.generateAppleStateChangedMessage(request)); + } + + @Async + public void sendGoogleStateChangedMessage(SlackChannel slackChannel, HttpServletRequest request, + Map.Entry> notification) + throws IOException, URISyntaxException { + send(slackChannel, messageFactory.generateGoogleStateChangedMessage(request, notification)); + } + + @Async + public void sendPurchaseMessage(SlackChannel slackChannel, HttpServletRequest request, Purchase purchase) + throws IOException { + send(slackChannel, messageFactory.generatePurchasePayload(request, purchase)); + } + + @Async + public void sendSignUpMessage(SlackChannel slackChannel, HttpServletRequest request) throws IOException { + send(slackChannel, messageFactory.generateSignUpPayload(request)); + } + + @Async + public void send(SlackChannel channel, com.slack.api.webhook.Payload payload) throws IOException { + final WebhookResponse response = slack.send(channel.SLACK_WEBHOOK_URL(), payload); + log.info(response.toString()); + } +} diff --git a/src/test/java/com/yello/server/domain/authorization/medium/AuthControllerTest.java b/src/test/java/com/yello/server/domain/authorization/medium/AuthControllerTest.java index 34bf804d..a223d472 100644 --- a/src/test/java/com/yello/server/domain/authorization/medium/AuthControllerTest.java +++ b/src/test/java/com/yello/server/domain/authorization/medium/AuthControllerTest.java @@ -32,6 +32,7 @@ import com.yello.server.domain.user.entity.Social; import com.yello.server.domain.user.entity.User; import com.yello.server.global.exception.ControllerExceptionAdvice; +import com.yello.server.infrastructure.slack.service.SlackService; import com.yello.server.util.TestDataEntityUtil; import com.yello.server.util.TestDataUtil; import com.yello.server.util.WithAccessTokenUser; @@ -96,6 +97,9 @@ class AuthControllerTest { @MockBean private AuthService authService; + @MockBean + private SlackService slackService; + private TestDataUtil testDataUtil = new TestDataEntityUtil(); private User user; private User target; diff --git a/src/test/java/com/yello/server/domain/purchase/FakePurchaseRepository.java b/src/test/java/com/yello/server/domain/purchase/FakePurchaseRepository.java index 7f5ff7e0..165ef4dc 100644 --- a/src/test/java/com/yello/server/domain/purchase/FakePurchaseRepository.java +++ b/src/test/java/com/yello/server/domain/purchase/FakePurchaseRepository.java @@ -1,5 +1,6 @@ package com.yello.server.domain.purchase; +import static com.yello.server.global.common.ErrorCode.NOT_EQUAL_TRANSACTION_EXCEPTION; import static com.yello.server.global.common.ErrorCode.NOT_FOUND_USER_SUBSCRIBE_EXCEPTION; import com.yello.server.domain.purchase.entity.ProductType; @@ -78,6 +79,14 @@ public Optional findByTransactionId(String transactionId) { .findFirst(); } + @Override + public Purchase getByTransactionId(String transactionId) { + return data.stream() + .filter(purchase -> purchase.getTransactionId().equals(transactionId)) + .findFirst() + .orElseThrow(() -> new PurchaseNotFoundException(NOT_EQUAL_TRANSACTION_EXCEPTION)); + } + @Override public Optional findByPurchaseToken(String purchaseToken) { return data.stream() diff --git a/src/test/java/com/yello/server/domain/purchase/medium/PurchaseControllerTest.java b/src/test/java/com/yello/server/domain/purchase/medium/PurchaseControllerTest.java index 2d8b7b16..3c6cac67 100644 --- a/src/test/java/com/yello/server/domain/purchase/medium/PurchaseControllerTest.java +++ b/src/test/java/com/yello/server/domain/purchase/medium/PurchaseControllerTest.java @@ -28,6 +28,7 @@ import com.yello.server.domain.purchase.service.PurchaseService; import com.yello.server.domain.user.entity.User; import com.yello.server.global.exception.ControllerExceptionAdvice; +import com.yello.server.infrastructure.slack.service.SlackService; import com.yello.server.util.TestDataEntityUtil; import com.yello.server.util.TestDataUtil; import com.yello.server.util.WithAccessTokenUser; @@ -90,6 +91,9 @@ class PurchaseControllerTest { @MockBean private PurchaseService purchaseService; + + @MockBean + private SlackService slackService; private TestDataUtil testDataUtil = new TestDataEntityUtil(); private User user; diff --git a/src/test/java/com/yello/server/domain/user/FakeUserRepository.java b/src/test/java/com/yello/server/domain/user/FakeUserRepository.java index 569a3e48..47ec026a 100644 --- a/src/test/java/com/yello/server/domain/user/FakeUserRepository.java +++ b/src/test/java/com/yello/server/domain/user/FakeUserRepository.java @@ -6,6 +6,7 @@ import static com.yello.server.global.common.ErrorCode.YELLOID_NOT_FOUND_USER_EXCEPTION; import com.yello.server.domain.friend.repository.FriendRepository; +import com.yello.server.domain.user.entity.Gender; import com.yello.server.domain.user.entity.User; import com.yello.server.domain.user.exception.UserNotFoundException; import com.yello.server.domain.user.repository.UserRepository; @@ -259,12 +260,13 @@ public List findAllByOtherGroupContainingYelloId(String groupName, String } @Override - public List findAllByGroupNameContainingAndFriendListNotContaining(String keyword, List uuidList, List friendList) { + public List findAllByGroupNameContainingAndFriendListNotContaining(String keyword, List uuidList, + List friendList) { return data.stream() - .filter(user -> user.getGroup().getGroupName().contains(keyword)) - .filter(user -> !user.getId().equals(1L)) - .filter(user -> !uuidList.contains(user.getUuid())) - .toList(); + .filter(user -> user.getGroup().getGroupName().contains(keyword)) + .filter(user -> !user.getId().equals(1L)) + .filter(user -> !uuidList.contains(user.getUuid())) + .toList(); } @Override @@ -288,6 +290,14 @@ public Long countAllByNameContaining(String name) { .size(); } + @Override + public Long countAllByGender(Gender gender) { + return (long) data.stream() + .filter(user -> user.getGender().equals(gender)) + .toList() + .size(); + } + @Override public Page findAll(Pageable pageable) { final List userList = data.stream()