From a47b8405f434bd28d82e4f27a44c5929dd70b980 Mon Sep 17 00:00:00 2001 From: ByungCheol Date: Sun, 28 Jun 2026 17:29:15 +0900 Subject: [PATCH] =?UTF-8?q?feat(billing):=20=EA=B5=AC=EB=A7=A4=20=EB=B3=B5?= =?UTF-8?q?=EC=9B=90=20+=20RTDN=20=EC=88=98=EC=8B=A0=20=EC=8A=A4=EC=BA=90?= =?UTF-8?q?=ED=8F=B4=EB=93=9C=20+=20=EB=A7=8C=EB=A3=8C=20=EA=B0=95?= =?UTF-8?q?=EB=93=B1=20=EC=A0=95=ED=95=A9=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 만료 강등 스케줄러: 해지(자동갱신 off) 건만 강등(자동갱신은 마켓/RTDN 관리) - POST /api/billing/restore: 최근 결제 기준 멤버십 복원(기기변경·재설치) - POST /api/billing/google/rtdn: Google Play 실시간 알림 수신 스캐폴드(비인증, 로깅) - 실연동 지점(영수증 검증·RTDN 처리)은 TODO 로 표시 Co-Authored-By: Claude Opus 4.8 --- .../com/sb/web/billing/BillingController.java | 18 ++++++++ .../com/sb/web/billing/BillingService.java | 45 +++++++++++++++++++ .../web/billing/mapper/IapPurchaseMapper.java | 3 ++ .../com/sb/web/common/config/WebConfig.java | 4 +- .../resources/mapper/IapPurchaseMapper.xml | 8 ++++ src/main/resources/mapper/MemberMapper.xml | 5 ++- 6 files changed, 80 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/sb/web/billing/BillingController.java b/src/main/java/com/sb/web/billing/BillingController.java index b8c6152..7d4b9d5 100644 --- a/src/main/java/com/sb/web/billing/BillingController.java +++ b/src/main/java/com/sb/web/billing/BillingController.java @@ -58,4 +58,22 @@ public class BillingController { @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { return billingService.resume(current.getId()); } + + /** 구매 복원 (기기 변경·재설치 시 최근 결제 기준 멤버십 복구) */ + @PostMapping("/restore") + public SubscriptionResponse restore( + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current, + HttpServletRequest request) { + return billingService.restore(current.getId(), AuthInterceptor.resolveToken(request)); + } + + /** + * Google Play 실시간 개발자 알림(RTDN) 수신 — Pub/Sub push (비인증). + * 스캐폴드: 수신/로깅만. 실연동 시 메시지 검증 + 구독상태(갱신/해지/환불) 동기화 구현. + */ + @PostMapping("/google/rtdn") + public org.springframework.http.ResponseEntity rtdn(@RequestBody(required = false) java.util.Map body) { + billingService.handleRtdn(body); + return org.springframework.http.ResponseEntity.ok().build(); + } } diff --git a/src/main/java/com/sb/web/billing/BillingService.java b/src/main/java/com/sb/web/billing/BillingService.java index d2a78ff..5dde86c 100644 --- a/src/main/java/com/sb/web/billing/BillingService.java +++ b/src/main/java/com/sb/web/billing/BillingService.java @@ -107,6 +107,31 @@ public class BillingService { return toSubscription(mustFind(memberId)); } + /** + * 구매 복원 — 기기 변경·재설치 시 최근 결제 기록 기준으로 멤버십을 복구한다. + * (실연동에서는 Google Play 의 활성 구매를 조회해 동기화하도록 교체) + */ + @Transactional + public SubscriptionResponse restore(Long memberId, String sessionToken) { + Member member = mustFind(memberId); + IapPurchase latest = purchaseMapper.findLatestByMember(memberId); + LocalDateTime now = LocalDateTime.now(); + boolean stillValid = latest != null && latest.getExpiresAt() != null && latest.getExpiresAt().isAfter(now); + boolean needsRestore = !"PREMIUM".equals(member.getPlan()) + || member.getPlanExpiresAt() == null + || member.getPlanExpiresAt().isBefore(latest != null && latest.getExpiresAt() != null ? latest.getExpiresAt() : now); + if (stillValid && needsRestore) { + memberMapper.updateMembership(memberId, "PREMIUM", latest.getExpiresAt(), latest.getProductId(), true); + authService.syncSessionPlan(sessionToken, "PREMIUM"); + member.setPlan("PREMIUM"); + member.setPlanExpiresAt(latest.getExpiresAt()); + member.setPlanProduct(latest.getProductId()); + member.setPlanAutoRenew(true); + log.info("[billing] 구매 복원 member={} expires={}", memberId, latest.getExpiresAt()); + } + return toSubscription(member); + } + /** 구독 해지 — 자동 갱신만 끈다. 이용은 만료일까지 유지(이후 무료 전환). */ @Transactional public SubscriptionResponse cancel(Long memberId) { @@ -147,6 +172,26 @@ public class BillingService { .build(); } + /** + * RTDN(실시간 개발자 알림) 처리 — 스캐폴드. + * 실연동: message.data(base64 JSON) 디코드 → subscriptionNotification 의 purchaseToken 으로 + * Play Developer API 재조회 후 멤버십 동기화(갱신/해지/만료/환불). + */ + public void handleRtdn(Map body) { + try { + Object message = body == null ? null : body.get("message"); + String data = null; + if (message instanceof Map m && m.get("data") != null) { + data = new String(java.util.Base64.getDecoder().decode(String.valueOf(m.get("data"))), + java.nio.charset.StandardCharsets.UTF_8); + } + log.info("[billing][rtdn] 수신 (스캐폴드 — 미처리): {}", data != null ? data : body); + } catch (Exception e) { + log.warn("[billing][rtdn] 파싱 실패: {}", e.toString()); + } + // TODO(실연동): 구독 상태 변경(SUBSCRIPTION_RENEWED/CANCELED/EXPIRED/REVOKED) 반영 + } + private Member mustFind(Long memberId) { Member m = memberMapper.findById(memberId); if (m == null) { diff --git a/src/main/java/com/sb/web/billing/mapper/IapPurchaseMapper.java b/src/main/java/com/sb/web/billing/mapper/IapPurchaseMapper.java index 5e7a6e6..506ffd6 100644 --- a/src/main/java/com/sb/web/billing/mapper/IapPurchaseMapper.java +++ b/src/main/java/com/sb/web/billing/mapper/IapPurchaseMapper.java @@ -14,4 +14,7 @@ public interface IapPurchaseMapper { /** 구매 토큰으로 기존 처리 여부 조회 (중복 결제 방지) */ IapPurchase findByToken(@Param("purchaseToken") String purchaseToken); + + /** 회원의 가장 최근(만료일 먼) 결제 — 구매 복원용 */ + IapPurchase findLatestByMember(@Param("memberId") Long memberId); } diff --git a/src/main/java/com/sb/web/common/config/WebConfig.java b/src/main/java/com/sb/web/common/config/WebConfig.java index 7cb219f..05cc21c 100644 --- a/src/main/java/com/sb/web/common/config/WebConfig.java +++ b/src/main/java/com/sb/web/common/config/WebConfig.java @@ -28,7 +28,9 @@ public class WebConfig implements WebMvcConfigurer { .addPathPatterns("/api/auth/me", "/api/auth/points", "/api/auth/point-history", "/api/auth/logout", "/api/auth/password", "/api/auth/verify-password", "/api/auth/profile", "/api/auth/profile-image", - "/api/board/**", "/api/admin/**", "/api/account/**", "/api/billing/**"); + "/api/board/**", "/api/admin/**", "/api/account/**", "/api/billing/**") + // Google Play RTDN(서버 알림)은 비인증 — Google 이 호출 + .excludePathPatterns("/api/billing/google/rtdn"); // 인가: 관리자 전용 경로 (authInterceptor 다음에 실행되어 role 검사) registry.addInterceptor(adminInterceptor) .addPathPatterns("/api/admin/**"); diff --git a/src/main/resources/mapper/IapPurchaseMapper.xml b/src/main/resources/mapper/IapPurchaseMapper.xml index c1e47d4..ef8159e 100644 --- a/src/main/resources/mapper/IapPurchaseMapper.xml +++ b/src/main/resources/mapper/IapPurchaseMapper.xml @@ -26,4 +26,12 @@ FROM iap_purchase WHERE purchase_token = #{purchaseToken} + + diff --git a/src/main/resources/mapper/MemberMapper.xml b/src/main/resources/mapper/MemberMapper.xml index 7e523f6..ff83fa5 100644 --- a/src/main/resources/mapper/MemberMapper.xml +++ b/src/main/resources/mapper/MemberMapper.xml @@ -117,10 +117,11 @@ UPDATE member SET plan_auto_renew = #{autoRenew}, updated_at = NOW() WHERE id = #{id} - + UPDATE member SET plan = 'FREE', plan_product = NULL, plan_auto_renew = 0, updated_at = NOW() - WHERE plan = 'PREMIUM' AND plan_expires_at IS NOT NULL AND plan_expires_at < NOW() + WHERE plan = 'PREMIUM' AND plan_auto_renew = 0 + AND plan_expires_at IS NOT NULL AND plan_expires_at < NOW()