diff --git a/Briefing-Api/build.gradle b/Briefing-Api/build.gradle index 19e57e8..33327cf 100644 --- a/Briefing-Api/build.gradle +++ b/Briefing-Api/build.gradle @@ -49,6 +49,12 @@ dependencies { //spring batch 의존성 implementation 'org.springframework.boot:spring-boot-starter-batch' + + // google play + implementation "com.google.api-client:google-api-client:1.33.0" + implementation 'com.google.auth:google-auth-library-oauth2-http:1.6.0' + implementation 'com.google.apis:google-api-services-androidpublisher:v3-rev20220411-1.32.1' + implementation 'com.google.http-client:google-http-client-jackson2:1.41.7' } tasks.named('test') { diff --git a/Briefing-Api/src/main/java/com/example/briefingapi/BriefingApiApplication.java b/Briefing-Api/src/main/java/com/example/briefingapi/BriefingApiApplication.java index 12037aa..6dea408 100644 --- a/Briefing-Api/src/main/java/com/example/briefingapi/BriefingApiApplication.java +++ b/Briefing-Api/src/main/java/com/example/briefingapi/BriefingApiApplication.java @@ -10,6 +10,7 @@ import org.springframework.cloud.openfeign.EnableFeignClients; import org.springframework.cloud.openfeign.FeignAutoConfiguration; import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; +import org.springframework.scheduling.annotation.EnableScheduling; @OpenAPIDefinition( @@ -20,6 +21,7 @@ }) @SpringBootApplication(scanBasePackages = {"com.example.briefingapi","com.example.briefingcommon","com.example.briefinginfra"}) @RequiredArgsConstructor +@EnableScheduling @EnableCaching @EnableFeignClients(basePackages = "com.example.briefinginfra") @EnableRedisRepositories diff --git a/Briefing-Api/src/main/java/com/example/briefingapi/config/GoogleCredentialsConfig.java b/Briefing-Api/src/main/java/com/example/briefingapi/config/GoogleCredentialsConfig.java new file mode 100644 index 0000000..f42a739 --- /dev/null +++ b/Briefing-Api/src/main/java/com/example/briefingapi/config/GoogleCredentialsConfig.java @@ -0,0 +1,37 @@ +package com.example.briefingapi.config; + +import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.json.gson.GsonFactory; +import com.google.api.services.androidpublisher.AndroidPublisher; +import com.google.api.services.androidpublisher.AndroidPublisherScopes; +import com.google.auth.http.HttpCredentialsAdapter; +import com.google.auth.oauth2.GoogleCredentials; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.security.GeneralSecurityException; + +@Component +public class GoogleCredentialsConfig { + + @Value("${subscription.google.keyfile.content}") + private String googleAccountFileContent; + + public AndroidPublisher androidPublisher() throws IOException, GeneralSecurityException { + InputStream inputStream = new ByteArrayInputStream(googleAccountFileContent.getBytes()); + GoogleCredentials credentials = GoogleCredentials.fromStream(inputStream) + .createScoped(AndroidPublisherScopes.ANDROIDPUBLISHER); + + JsonFactory jsonFactory = GsonFactory.getDefaultInstance(); + + return new AndroidPublisher.Builder( + GoogleNetHttpTransport.newTrustedTransport(), + jsonFactory, + new HttpCredentialsAdapter(credentials)) + .build(); + } +} diff --git a/Briefing-Api/src/main/java/com/example/briefingapi/member/business/MemberService.java b/Briefing-Api/src/main/java/com/example/briefingapi/member/business/MemberService.java index 683cc83..f714749 100644 --- a/Briefing-Api/src/main/java/com/example/briefingapi/member/business/MemberService.java +++ b/Briefing-Api/src/main/java/com/example/briefingapi/member/business/MemberService.java @@ -1,12 +1,11 @@ package com.example.briefingapi.member.business; -import java.util.List; - import com.example.briefingapi.fcm.implementation.FcmCommandService; import com.example.briefingapi.member.implement.MemberCommandAdapter; import com.example.briefingapi.member.implement.MemberQueryAdapter; import com.example.briefingapi.member.presentation.dto.MemberRequest; import com.example.briefingapi.member.presentation.dto.MemberResponse; +import com.example.briefingapi.redis.service.RedisService; import com.example.briefingapi.security.provider.TokenProvider; import com.example.briefingcommon.entity.Member; import com.example.briefingcommon.entity.enums.MemberRole; @@ -15,16 +14,13 @@ import com.example.briefinginfra.feign.oauth.apple.client.AppleOauth2Client; import com.example.briefinginfra.feign.oauth.google.client.GoogleOauth2Client; import com.example.briefinginfra.feign.oauth.google.dto.GoogleUserInfo; +import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.stereotype.Component; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; - -import com.example.briefingapi.redis.service.RedisService; - -import lombok.RequiredArgsConstructor; +import java.util.List; @Service @RequiredArgsConstructor @@ -79,10 +75,10 @@ public MemberResponse.TestTokenDTO getTestToken(){ return MemberResponse.TestTokenDTO.builder() .token( tokenProvider.createAccessToken( - member.getId(), - member.getSocialType().toString(), - member.getSocialId(), - List.of(new SimpleGrantedAuthority(MemberRole.ROLE_USER.name())))) + member.getId(), + member.getSocialType().toString(), + member.getSocialId(), + List.of(new SimpleGrantedAuthority(MemberRole.ROLE_USER.name())))) .refeshToken(redisService.generateTestRefreshToken()) .build(); } @@ -116,4 +112,4 @@ public void subScribeDailyPush(MemberRequest.ToggleDailyPushAlarmDTO request, Me fcmCommandService.unSubScribe(dailyPushTopic,request.getFcmToken()); } } -} +} \ No newline at end of file diff --git a/Briefing-Api/src/main/java/com/example/briefingapi/security/handler/annotation/AuthMember.java b/Briefing-Api/src/main/java/com/example/briefingapi/security/handler/annotation/AuthMember.java index 432665c..6ad7958 100644 --- a/Briefing-Api/src/main/java/com/example/briefingapi/security/handler/annotation/AuthMember.java +++ b/Briefing-Api/src/main/java/com/example/briefingapi/security/handler/annotation/AuthMember.java @@ -1,5 +1,7 @@ package com.example.briefingapi.security.handler.annotation; +import io.swagger.v3.oas.annotations.Parameter; + import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -7,4 +9,5 @@ @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.PARAMETER) +@Parameter(hidden = true) public @interface AuthMember {} diff --git a/Briefing-Api/src/main/java/com/example/briefingapi/security/provider/TokenProvider.java b/Briefing-Api/src/main/java/com/example/briefingapi/security/provider/TokenProvider.java index 456e494..4444edc 100644 --- a/Briefing-Api/src/main/java/com/example/briefingapi/security/provider/TokenProvider.java +++ b/Briefing-Api/src/main/java/com/example/briefingapi/security/provider/TokenProvider.java @@ -1,15 +1,10 @@ package com.example.briefingapi.security.provider; -import java.security.Key; -import java.util.Arrays; -import java.util.Base64; -import java.util.Collection; -import java.util.Date; -import java.util.stream.Collectors; import com.example.briefingapi.exception.JwtAuthenticationException; import com.example.briefingcommon.common.exception.common.ErrorCode; +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; import jakarta.servlet.http.HttpServletRequest; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.InitializingBean; @@ -22,8 +17,14 @@ import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; -import io.jsonwebtoken.*; -import io.jsonwebtoken.security.Keys; +import java.security.Key; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collection; +import java.util.Date; +import java.util.stream.Collectors; @Component public class TokenProvider implements InitializingBean { @@ -38,8 +39,6 @@ public class TokenProvider implements InitializingBean { private final long accessTokenValidityInMilliseconds; - // private final RefreshTokenRepository refreshTokenRepository; - private Key key; public enum TokenType { @@ -50,12 +49,10 @@ public enum TokenType { public TokenProvider( @Value("${jwt.secret}") String secret, @Value("${jwt.authorities-key}") String authoritiesKey, - @Value("${jwt.access-token-validity-in-seconds}") - long accessTokenValidityInMilliseconds) { + @Value("${jwt.access-token-validity-in-seconds}") long accessTokenValidityInSeconds) { this.secret = secret; this.AUTHORITIES_KEY = authoritiesKey; - this.accessTokenValidityInMilliseconds = accessTokenValidityInMilliseconds; - // this.refreshTokenRepository = refreshTokenRepository; + this.accessTokenValidityInMilliseconds = accessTokenValidityInSeconds * 1000; } @Override @@ -64,82 +61,51 @@ public void afterPropertiesSet() throws Exception { this.key = Keys.hmacShaKeyFor(keyBytes); } - // 수정 해야함 - public String createAccessToken( - Long userId, - String socialType, - String socialId, - Collection authorities) { - long now = (new Date()).getTime(); - Date validity = new Date(now + this.accessTokenValidityInMilliseconds); + public String createAccessToken(Long userId, String socialType, String socialId, Collection authorities) { + Instant issuedAt = Instant.now().truncatedTo(ChronoUnit.SECONDS); + Instant expiration = issuedAt.plus(accessTokenValidityInMilliseconds, ChronoUnit.MILLIS); return Jwts.builder() .setSubject(String.valueOf(userId)) - .claim(AUTHORITIES_KEY, authorities) + .claim(AUTHORITIES_KEY, authorities.stream().map(GrantedAuthority::getAuthority).collect(Collectors.joining(","))) .claim("socialType", socialType) .claim("socialID", socialId) + .setIssuedAt(Date.from(issuedAt)) + .setExpiration(Date.from(expiration)) .signWith(key, SignatureAlgorithm.HS512) - .setExpiration(validity) - .compact(); - } - - public String createAccessToken( - Long userId, String phoneNum, Collection authorities) { - long now = (new Date()).getTime(); - Date validity = new Date(now + this.accessTokenValidityInMilliseconds); - - return Jwts.builder() - .setSubject(String.valueOf(userId)) - .claim(AUTHORITIES_KEY, authorities) - .claim("phoneNum", phoneNum) - .signWith(key, SignatureAlgorithm.HS512) - .setExpiration(validity) .compact(); } public Authentication getAuthentication(String token) { - Claims claims = - Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody(); + Claims claims = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody(); - Collection authorities = - Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(",")) - .map(SimpleGrantedAuthority::new) - .collect(Collectors.toList()); + Collection authorities = Arrays.stream(claims.get(AUTHORITIES_KEY).toString().split(",")) + .map(SimpleGrantedAuthority::new) + .collect(Collectors.toList()); User principal = new User(claims.getSubject(), "", authorities); return new UsernamePasswordAuthenticationToken(principal, token, authorities); } public boolean validateToken(String token, TokenType type) throws JwtAuthenticationException { try { - Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); + Jws claimsJws = Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token); + LOGGER.info("JWT Token is valid. Expiration: {}", claimsJws.getBody().getExpiration()); return true; } catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e) { + LOGGER.error("Invalid JWT signature: {}", e.getMessage()); throw new JwtAuthenticationException(ErrorCode.INVALID_TOKEN_EXCEPTION); } catch (ExpiredJwtException e) { - if (type == TokenType.ACCESS) - throw new JwtAuthenticationException(ErrorCode.EXPIRED_JWT_EXCEPTION); - else throw new JwtAuthenticationException(ErrorCode.RELOGIN_EXCEPTION); + LOGGER.warn("Expired JWT token: {}", e.getMessage()); + throw new JwtAuthenticationException(ErrorCode.EXPIRED_JWT_EXCEPTION); } catch (UnsupportedJwtException e) { + LOGGER.error("Unsupported JWT token: {}", e.getMessage()); throw new JwtAuthenticationException(ErrorCode.INVALID_TOKEN_EXCEPTION); } catch (IllegalArgumentException e) { + LOGGER.error("JWT token compact of handler are invalid: {}", e.getMessage()); throw new JwtAuthenticationException(ErrorCode.INVALID_TOKEN_EXCEPTION); } } - // public Long validateAndReturnId(String token) throws JwtAuthenticationException{ - // try{ - // Claims body = - // Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token).getBody(); - // return Long.valueOf(body.getSubject()); - // }catch (io.jsonwebtoken.security.SecurityException | MalformedJwtException e){ - // throw new JwtAuthenticationException(Er.JWT_BAD_REQUEST); - // }catch (UnsupportedJwtException e){ - // throw new JwtAuthenticationException(Code.JWT_UNSUPPORTED_TOKEN); - // }catch (IllegalArgumentException e){ - // throw new JwtAuthenticationException(Code.JWT_BAD_REQUEST); - // } - // } - public String resolveToken(HttpServletRequest request) { String bearerToken = request.getHeader(AUTHORIZATION_HEADER); if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) { diff --git a/Briefing-Api/src/main/java/com/example/briefingapi/subscription/business/SubscriptionMapper.java b/Briefing-Api/src/main/java/com/example/briefingapi/subscription/business/SubscriptionMapper.java new file mode 100644 index 0000000..60fea55 --- /dev/null +++ b/Briefing-Api/src/main/java/com/example/briefingapi/subscription/business/SubscriptionMapper.java @@ -0,0 +1,35 @@ +package com.example.briefingapi.subscription.business; + +import com.example.briefingapi.subscription.presentation.dto.SubscriptionRequest; +import com.example.briefingapi.subscription.presentation.dto.SubscriptionResponse; +import com.example.briefingcommon.entity.Member; +import com.example.briefingcommon.entity.Subscription; +import com.example.briefingcommon.entity.enums.SubscriptionStatus; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class SubscriptionMapper { + + public static Subscription toSubscription(Member member, SubscriptionRequest.ReceiptDTO request, LocalDateTime expiryDate) { + return Subscription.builder() + .member(member) + .type(request.getSubscriptionType()) + .status(LocalDateTime.now().isBefore(expiryDate) ? SubscriptionStatus.ACTIVE : SubscriptionStatus.EXPIRED) + .expiryDate(expiryDate) + .build(); + } + + public static SubscriptionResponse.SubscriptionDTO toSubscriptionDTO(Subscription subscription) { + return SubscriptionResponse.SubscriptionDTO.builder() + .id(subscription.getId()) + .memberId(subscription.getMember().getId()) + .type(subscription.getType()) + .status(subscription.getStatus()) + .expiryDate(subscription.getExpiryDate()) + .build(); + } + +} diff --git a/Briefing-Api/src/main/java/com/example/briefingapi/subscription/business/SubscriptionService.java b/Briefing-Api/src/main/java/com/example/briefingapi/subscription/business/SubscriptionService.java new file mode 100644 index 0000000..a6bd384 --- /dev/null +++ b/Briefing-Api/src/main/java/com/example/briefingapi/subscription/business/SubscriptionService.java @@ -0,0 +1,105 @@ +package com.example.briefingapi.subscription.business; + +import com.example.briefingapi.config.GoogleCredentialsConfig; +import com.example.briefingapi.subscription.implement.SubscriptionCommandAdapter; +import com.example.briefingapi.subscription.implement.SubscriptionQueryAdapter; +import com.example.briefingapi.subscription.presentation.dto.SubscriptionRequest; +import com.example.briefingapi.subscription.presentation.dto.SubscriptionResponse; +import com.example.briefingcommon.common.exception.SubscriptionException; +import com.example.briefingcommon.common.exception.common.ErrorCode; +import com.example.briefingcommon.entity.Member; +import com.example.briefingcommon.entity.Subscription; +import com.google.api.services.androidpublisher.AndroidPublisher; +import com.google.api.services.androidpublisher.model.SubscriptionPurchase; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.List; + +import static com.example.briefingcommon.entity.enums.SubscriptionStatus.ACTIVE; +import static com.example.briefingcommon.entity.enums.SubscriptionStatus.EXPIRED; + +@Service +@RequiredArgsConstructor +public class SubscriptionService { + + private final SubscriptionCommandAdapter subscriptionCommandAdapter; + private final SubscriptionQueryAdapter subscriptionQueryAdapter; + private final GoogleCredentialsConfig googleCredentialsConfig; + + public SubscriptionPurchase googleInAppPurchaseVerify(String packageName, String productId, String purchaseToken) { + try { + AndroidPublisher publisher = googleCredentialsConfig.androidPublisher(); + AndroidPublisher.Purchases.Subscriptions.Get request = publisher.purchases().subscriptions() + .get(packageName, productId, purchaseToken); + SubscriptionPurchase purchase = request.execute(); + + // 결제가 완료되지 않은 경우 예외 발생 + if (purchase.getPaymentState() != null && purchase.getPaymentState() != 1) { + throw new SubscriptionException(ErrorCode.PAYMENT_NOT_COMPLETED); + } + + return purchase; + } catch (GeneralSecurityException | IOException e) { + throw new SubscriptionException(ErrorCode.INVALID_SUBSCRIPTION); + } + } + + @Transactional + public void handleSubscriptionCreation(final Member member, final SubscriptionRequest.ReceiptDTO request, SubscriptionPurchase purchase) { + boolean activeSubscriptionExists = subscriptionQueryAdapter.existsByMemberIdAndStatus(member.getId(), ACTIVE); + + if (activeSubscriptionExists) { + throw new SubscriptionException(ErrorCode.ACTIVE_SUBSCRIPTION_EXISTS); + } + + LocalDateTime expiryDate = LocalDateTime.ofEpochSecond(purchase.getExpiryTimeMillis() / 1000, 0, ZoneOffset.UTC); + Subscription subscription = SubscriptionMapper.toSubscription(member, request, expiryDate); + + subscriptionCommandAdapter.create(subscription); + } + + @Transactional + public SubscriptionResponse.SubscriptionDTO getActiveSubscriptionByMemberId(Member member, final Long memberId) { + validateMember(member, memberId); + + Subscription subscription = subscriptionQueryAdapter.findFirstByMemberIdAndStatusOrderByExpiryDateDesc(memberId, ACTIVE) + .orElseThrow(() -> new SubscriptionException(ErrorCode.SUBSCRIPTION_NOT_FOUND)); + + updateSubscriptionStatus(subscription); + + return SubscriptionMapper.toSubscriptionDTO(subscription); + } + + @Scheduled(cron = "0 0 0 * * ?") + @Transactional + public void updateExpiredSubscriptions() { + List activeSubscriptions = subscriptionQueryAdapter.findAllActiveSubscriptions(); + LocalDateTime now = LocalDateTime.now(); + + for (Subscription subscription : activeSubscriptions) { + if (now.isAfter(subscription.getExpiryDate())) { + updateSubscriptionStatus(subscription); + } + } + } + + private void updateSubscriptionStatus(Subscription subscription) { + if (LocalDateTime.now().isAfter(subscription.getExpiryDate())) { + subscriptionCommandAdapter.updateSubscriptionStatus(subscription, EXPIRED); + } + } + + private void validateMember(Member member, Long memberId) { + if (!member.getId().equals(memberId)) { + throw new SubscriptionException(ErrorCode.MEMBER_NOT_SAME); + } + } + +} \ No newline at end of file diff --git a/Briefing-Api/src/main/java/com/example/briefingapi/subscription/implement/SubscriptionCommandAdapter.java b/Briefing-Api/src/main/java/com/example/briefingapi/subscription/implement/SubscriptionCommandAdapter.java new file mode 100644 index 0000000..f36a0f4 --- /dev/null +++ b/Briefing-Api/src/main/java/com/example/briefingapi/subscription/implement/SubscriptionCommandAdapter.java @@ -0,0 +1,22 @@ +package com.example.briefingapi.subscription.implement; + +import com.example.briefingapi.annotation.Adapter; +import com.example.briefingcommon.domain.repository.subscription.SubscriptionRepository; +import com.example.briefingcommon.entity.Subscription; +import com.example.briefingcommon.entity.enums.SubscriptionStatus; +import lombok.RequiredArgsConstructor; + +@Adapter +@RequiredArgsConstructor +public class SubscriptionCommandAdapter { + + private final SubscriptionRepository subscriptionRepository; + + public Subscription create(final Subscription subscription) { + return subscriptionRepository.save(subscription); + } + + public void updateSubscriptionStatus(Subscription subscription, final SubscriptionStatus status) { + subscription.updateSubscriptionStatus(status); + } +} diff --git a/Briefing-Api/src/main/java/com/example/briefingapi/subscription/implement/SubscriptionQueryAdapter.java b/Briefing-Api/src/main/java/com/example/briefingapi/subscription/implement/SubscriptionQueryAdapter.java new file mode 100644 index 0000000..ca7a0ce --- /dev/null +++ b/Briefing-Api/src/main/java/com/example/briefingapi/subscription/implement/SubscriptionQueryAdapter.java @@ -0,0 +1,37 @@ +package com.example.briefingapi.subscription.implement; + +import com.example.briefingapi.annotation.Adapter; +import com.example.briefingcommon.domain.repository.subscription.SubscriptionRepository; +import com.example.briefingcommon.entity.Subscription; +import com.example.briefingcommon.entity.enums.SubscriptionStatus; +import lombok.RequiredArgsConstructor; + +import java.util.List; +import java.util.Optional; + +@Adapter +@RequiredArgsConstructor +public class SubscriptionQueryAdapter { + + private final SubscriptionRepository subscriptionRepository; + + public Optional findByMemberId(Long memberId) { + return subscriptionRepository.findFirstByMemberIdOrderByExpiryDateDesc(memberId); + } + + public List findAllByMemberId(Long memberId) { + return subscriptionRepository.findAllByMemberId(memberId); + } + + public List findAllActiveSubscriptions() { + return subscriptionRepository.findAllByStatus(SubscriptionStatus.ACTIVE); + } + + public boolean existsByMemberIdAndStatus(Long memberId, SubscriptionStatus status) { + return subscriptionRepository.existsByMemberIdAndStatus(memberId, status); + } + + public Optional findFirstByMemberIdAndStatusOrderByExpiryDateDesc(Long memberId, SubscriptionStatus status) { + return subscriptionRepository.findFirstByMemberIdAndStatusOrderByExpiryDateDesc(memberId, status); + } +} diff --git a/Briefing-Api/src/main/java/com/example/briefingapi/subscription/presentation/SubscriptionApi.java b/Briefing-Api/src/main/java/com/example/briefingapi/subscription/presentation/SubscriptionApi.java new file mode 100644 index 0000000..0b05df7 --- /dev/null +++ b/Briefing-Api/src/main/java/com/example/briefingapi/subscription/presentation/SubscriptionApi.java @@ -0,0 +1,37 @@ +package com.example.briefingapi.subscription.presentation; + +import com.example.briefingapi.security.handler.annotation.AuthMember; +import com.example.briefingapi.subscription.business.SubscriptionService; +import com.example.briefingapi.subscription.presentation.dto.SubscriptionRequest; +import com.example.briefingapi.subscription.presentation.dto.SubscriptionResponse; +import com.example.briefingcommon.common.presentation.response.CommonResponse; +import com.example.briefingcommon.entity.Member; +import com.google.api.services.androidpublisher.model.SubscriptionPurchase; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "06-Subscription V2 💳", description = "구독 관련 API V2") +@RestController +@RequiredArgsConstructor +@RequestMapping("/v2/subscriptions") +public class SubscriptionApi { + + private final SubscriptionService subscriptionService; + + @Operation(summary = "06-01 Subscription 💳 영수증 검증 및 구독하기 V2", description = "결제 영수증을 검증하고, 구독 정보를 갱신하는 API입니다.") + @PostMapping + public CommonResponse createSubscription(@AuthMember Member member, @RequestBody SubscriptionRequest.ReceiptDTO request) { + SubscriptionPurchase purchase = subscriptionService.googleInAppPurchaseVerify(request.getPackageName(), request.getProductId(), request.getPurchaseToken()); + subscriptionService.handleSubscriptionCreation(member, request, purchase); + return CommonResponse.onSuccess(); + } + + @Operation(summary = "06-02 Subscription 💳 구독 정보 조회하기 V2", description = "구독 정보를 조회하는 API입니다. 만료된 구독 정보는 제외합니다.") + @GetMapping("/members/{memberId}") + public CommonResponse getSubscriptionByMemberId(@AuthMember Member member, @PathVariable("memberId") Long memberId) { + return CommonResponse.onSuccess(subscriptionService.getActiveSubscriptionByMemberId(member, memberId)); + } + +} diff --git a/Briefing-Api/src/main/java/com/example/briefingapi/subscription/presentation/dto/SubscriptionRequest.java b/Briefing-Api/src/main/java/com/example/briefingapi/subscription/presentation/dto/SubscriptionRequest.java new file mode 100644 index 0000000..04fc1a7 --- /dev/null +++ b/Briefing-Api/src/main/java/com/example/briefingapi/subscription/presentation/dto/SubscriptionRequest.java @@ -0,0 +1,19 @@ +package com.example.briefingapi.subscription.presentation.dto; + +import com.example.briefingcommon.entity.enums.SubscriptionType; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class SubscriptionRequest { + + @Getter + public static class ReceiptDTO { + private String packageName; + private String productId; + private String purchaseToken; + private SubscriptionType subscriptionType; + } + +} diff --git a/Briefing-Api/src/main/java/com/example/briefingapi/subscription/presentation/dto/SubscriptionResponse.java b/Briefing-Api/src/main/java/com/example/briefingapi/subscription/presentation/dto/SubscriptionResponse.java new file mode 100644 index 0000000..19469ed --- /dev/null +++ b/Briefing-Api/src/main/java/com/example/briefingapi/subscription/presentation/dto/SubscriptionResponse.java @@ -0,0 +1,24 @@ +package com.example.briefingapi.subscription.presentation.dto; + +import com.example.briefingcommon.entity.enums.SubscriptionStatus; +import com.example.briefingcommon.entity.enums.SubscriptionType; +import lombok.*; + +import java.time.LocalDateTime; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class SubscriptionResponse { + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class SubscriptionDTO { + private Long id; + private Long memberId; + private SubscriptionType type; + private SubscriptionStatus status; + private LocalDateTime expiryDate; + } + +} diff --git a/Briefing-Api/src/main/resources/application.yml b/Briefing-Api/src/main/resources/application.yml index ac2c9d5..b626b84 100644 --- a/Briefing-Api/src/main/resources/application.yml +++ b/Briefing-Api/src/main/resources/application.yml @@ -78,6 +78,15 @@ openai: url: chat: https://api.openai.com/v1/chat/completions +subscription: + google: + package-name: com.dev.briefing + keyfile: + content: ${SUBSCRIPTION_GOOGLE_KEYFILE_CONTENT} + + application: + name: briefing + --- spring: application: @@ -130,6 +139,12 @@ openai: token: ${OPEN_API_TOKEN} url: chat: https://api.openai.com/v1/chat/completions + +subscription: + google: + package-name: com.dev.briefing + keyfile: + content: ${SUBSCRIPTION_GOOGLE_KEYFILE_CONTENT} --- spring: config: @@ -170,6 +185,12 @@ openai: token: ${OPEN_API_TOKEN} url: chat: https://api.openai.com/v1/chat/completions + +subscription: + google: + package-name: com.dev.briefing + keyfile: + content: ${SUBSCRIPTION_GOOGLE_KEYFILE_CONTENT} --- spring: config: diff --git a/core/Briefing-Common/src/main/generated/com/example/briefingcommon/entity/QSubscription.java b/core/Briefing-Common/src/main/generated/com/example/briefingcommon/entity/QSubscription.java new file mode 100644 index 0000000..7be99e7 --- /dev/null +++ b/core/Briefing-Common/src/main/generated/com/example/briefingcommon/entity/QSubscription.java @@ -0,0 +1,65 @@ +package com.example.briefingcommon.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QSubscription is a Querydsl query type for Subscription + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QSubscription extends EntityPathBase { + + private static final long serialVersionUID = 1308632478L; + + private static final PathInits INITS = PathInits.DIRECT2; + + public static final QSubscription subscription = new QSubscription("subscription"); + + public final QBaseDateTimeEntity _super = new QBaseDateTimeEntity(this); + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + public final DateTimePath expiryDate = createDateTime("expiryDate", java.time.LocalDateTime.class); + + public final NumberPath id = createNumber("id", Long.class); + + public final QMember member; + + public final EnumPath status = createEnum("status", com.example.briefingcommon.entity.enums.SubscriptionStatus.class); + + public final EnumPath type = createEnum("type", com.example.briefingcommon.entity.enums.SubscriptionType.class); + + //inherited + public final DateTimePath updatedAt = _super.updatedAt; + + public QSubscription(String variable) { + this(Subscription.class, forVariable(variable), INITS); + } + + public QSubscription(Path path) { + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); + } + + public QSubscription(PathMetadata metadata) { + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QSubscription(PathMetadata metadata, PathInits inits) { + this(Subscription.class, metadata, inits); + } + + public QSubscription(Class type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.member = inits.isInitialized("member") ? new QMember(forProperty("member")) : null; + } + +} + diff --git a/core/Briefing-Common/src/main/java/com/example/briefingcommon/common/exception/SubscriptionException.java b/core/Briefing-Common/src/main/java/com/example/briefingcommon/common/exception/SubscriptionException.java new file mode 100644 index 0000000..d5e0ead --- /dev/null +++ b/core/Briefing-Common/src/main/java/com/example/briefingcommon/common/exception/SubscriptionException.java @@ -0,0 +1,11 @@ +package com.example.briefingcommon.common.exception; + + +import com.example.briefingcommon.common.exception.common.ErrorCode; +import com.example.briefingcommon.common.exception.common.GeneralException; + +public class SubscriptionException extends GeneralException { + public SubscriptionException(ErrorCode errorCode) { + super(errorCode); + } +} diff --git a/core/Briefing-Common/src/main/java/com/example/briefingcommon/common/exception/common/ErrorCode.java b/core/Briefing-Common/src/main/java/com/example/briefingcommon/common/exception/common/ErrorCode.java index 745792e..3b91328 100644 --- a/core/Briefing-Common/src/main/java/com/example/briefingcommon/common/exception/common/ErrorCode.java +++ b/core/Briefing-Common/src/main/java/com/example/briefingcommon/common/exception/common/ErrorCode.java @@ -1,17 +1,14 @@ package com.example.briefingcommon.common.exception.common; -import static org.springframework.http.HttpStatus.*; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import java.util.Arrays; import java.util.Optional; import java.util.function.Predicate; -import org.springframework.http.HttpStatus; - -import lombok.Getter; -import lombok.RequiredArgsConstructor; - @Getter @RequiredArgsConstructor public enum ErrorCode { @@ -68,7 +65,14 @@ public enum ErrorCode { BAD_LAST_MESSAGE_ROLE(HttpStatus.BAD_REQUEST, "CHATTING_003", "마지막 메시지의 역할이 user가 아닙니다."), CAN_NOT_EMPTY_CONTENT(HttpStatus.BAD_REQUEST, "CHATTING_004", "content가 비어있습니다."), NOT_FOUND_ROLE(HttpStatus.BAD_REQUEST, "CHATTING_005", "해당하는 role을 찾을 수 없습니다."), - NOT_FOUND_MODEL(HttpStatus.BAD_REQUEST, "CHATTING_006", "해당하는 mode을 찾을 수 없습니다."); + NOT_FOUND_MODEL(HttpStatus.BAD_REQUEST, "CHATTING_006", "해당하는 model을 찾을 수 없습니다."), + + // subscription 에러 + SUBSCRIPTION_NOT_FOUND(HttpStatus.NOT_FOUND, "SUBSCRIPTION_001", "구독 정보가 존재하지 않습니다."), + INVALID_SUBSCRIPTION(HttpStatus.BAD_REQUEST, "SUBSCRIPTION_002", "유효하지 않은 구독 정보입니다. packageName, productId, purchaseToken을 확인해 주세요."), + INVALID_SUBSCRIPTION_TYPE(HttpStatus.BAD_REQUEST, "SUBSCRIPTION_003", "유효하지 않은 구독 유형입니다."), + ACTIVE_SUBSCRIPTION_EXISTS(HttpStatus.CONFLICT, "SUBSCRIPTION_004", "활성된 구독이 이미 존재합니다."), + PAYMENT_NOT_COMPLETED(HttpStatus.BAD_REQUEST, "SUBSCRIPTION_005", "구매가 완료되지 않았습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/core/Briefing-Common/src/main/java/com/example/briefingcommon/domain/repository/subscription/SubscriptionRepository.java b/core/Briefing-Common/src/main/java/com/example/briefingcommon/domain/repository/subscription/SubscriptionRepository.java new file mode 100644 index 0000000..bed84c1 --- /dev/null +++ b/core/Briefing-Common/src/main/java/com/example/briefingcommon/domain/repository/subscription/SubscriptionRepository.java @@ -0,0 +1,22 @@ +package com.example.briefingcommon.domain.repository.subscription; + +import com.example.briefingcommon.entity.Subscription; +import com.example.briefingcommon.entity.enums.SubscriptionStatus; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface SubscriptionRepository extends JpaRepository { + + Optional findFirstByMemberIdOrderByExpiryDateDesc(Long memberId); + + List findAllByMemberId(Long memberId); + + List findAllByStatus(SubscriptionStatus subscriptionStatus); + + boolean existsByMemberIdAndStatus(Long memberId, SubscriptionStatus status); + + Optional findFirstByMemberIdAndStatusOrderByExpiryDateDesc(Long memberId, SubscriptionStatus status); + +} diff --git a/core/Briefing-Common/src/main/java/com/example/briefingcommon/entity/Subscription.java b/core/Briefing-Common/src/main/java/com/example/briefingcommon/entity/Subscription.java new file mode 100644 index 0000000..5970d47 --- /dev/null +++ b/core/Briefing-Common/src/main/java/com/example/briefingcommon/entity/Subscription.java @@ -0,0 +1,38 @@ +package com.example.briefingcommon.entity; + +import com.example.briefingcommon.entity.enums.SubscriptionStatus; +import com.example.briefingcommon.entity.enums.SubscriptionType; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +public class Subscription extends BaseDateTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "member_id") + private Member member; + + @Enumerated(EnumType.STRING) + private SubscriptionType type; + + @Setter + @Enumerated(EnumType.STRING) + private SubscriptionStatus status; + + @Setter + private LocalDateTime expiryDate; + + public void updateSubscriptionStatus(SubscriptionStatus status) { + this.status = status; + } +} diff --git a/core/Briefing-Common/src/main/java/com/example/briefingcommon/entity/enums/SubscriptionStatus.java b/core/Briefing-Common/src/main/java/com/example/briefingcommon/entity/enums/SubscriptionStatus.java new file mode 100644 index 0000000..078672a --- /dev/null +++ b/core/Briefing-Common/src/main/java/com/example/briefingcommon/entity/enums/SubscriptionStatus.java @@ -0,0 +1,14 @@ +package com.example.briefingcommon.entity.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SubscriptionStatus { + ACTIVE("Active"), + EXPIRED("Expired"), + CANCELLED("Cancelled"); + + private final String description; +} diff --git a/core/Briefing-Common/src/main/java/com/example/briefingcommon/entity/enums/SubscriptionType.java b/core/Briefing-Common/src/main/java/com/example/briefingcommon/entity/enums/SubscriptionType.java new file mode 100644 index 0000000..be0e432 --- /dev/null +++ b/core/Briefing-Common/src/main/java/com/example/briefingcommon/entity/enums/SubscriptionType.java @@ -0,0 +1,13 @@ +package com.example.briefingcommon.entity.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum SubscriptionType { + MONTHLY("Monthly"), + ANNUAL("Annual"); + + private final String description; +} \ No newline at end of file diff --git a/core/Briefing-Infra/src/main/java/com/example/briefinginfra/feign/nickname/hwanmoo/client/NickNameClient.java b/core/Briefing-Infra/src/main/java/com/example/briefinginfra/feign/nickname/hwanmoo/client/NickNameClient.java index 4120542..8c2d6e7 100644 --- a/core/Briefing-Infra/src/main/java/com/example/briefinginfra/feign/nickname/hwanmoo/client/NickNameClient.java +++ b/core/Briefing-Infra/src/main/java/com/example/briefinginfra/feign/nickname/hwanmoo/client/NickNameClient.java @@ -12,6 +12,12 @@ ) @Component public interface NickNameClient { + @GetMapping(value = "/") - NickNameRes getNickName(@RequestParam(defaultValue = "json") String format, @RequestParam(defaultValue = "1") int count, @RequestParam(defaultValue = "8") int max_length); + NickNameRes getNickName( + @RequestParam(value = "format", defaultValue = "json") String format, + @RequestParam(value = "count", defaultValue = "1") int count, + @RequestParam(value = "max_length", defaultValue = "8") int maxLength + ); + }