feat(billing): 구매 복원 + RTDN 수신 스캐폴드 + 만료 강등 정합성
- 만료 강등 스케줄러: 해지(자동갱신 off) 건만 강등(자동갱신은 마켓/RTDN 관리) - POST /api/billing/restore: 최근 결제 기준 멤버십 복원(기기변경·재설치) - POST /api/billing/google/rtdn: Google Play 실시간 알림 수신 스캐폴드(비인증, 로깅) - 실연동 지점(영수증 검증·RTDN 처리)은 TODO 로 표시 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
8142b8b518
commit
a47b8405f4
@@ -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<Void> rtdn(@RequestBody(required = false) java.util.Map<String, Object> body) {
|
||||
billingService.handleRtdn(body);
|
||||
return org.springframework.http.ResponseEntity.ok().build();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String, Object> 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) {
|
||||
|
||||
@@ -14,4 +14,7 @@ public interface IapPurchaseMapper {
|
||||
|
||||
/** 구매 토큰으로 기존 처리 여부 조회 (중복 결제 방지) */
|
||||
IapPurchase findByToken(@Param("purchaseToken") String purchaseToken);
|
||||
|
||||
/** 회원의 가장 최근(만료일 먼) 결제 — 구매 복원용 */
|
||||
IapPurchase findLatestByMember(@Param("memberId") Long memberId);
|
||||
}
|
||||
|
||||
@@ -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/**");
|
||||
|
||||
Reference in New Issue
Block a user