feat(billing): 구매 복원 + RTDN 수신 스캐폴드 + 만료 강등 정합성
CI / build (push) Failing after 12m15s

- 만료 강등 스케줄러: 해지(자동갱신 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:
ByungCheol
2026-06-28 17:29:15 +09:00
parent 8142b8b518
commit a47b8405f4
6 changed files with 80 additions and 3 deletions
@@ -58,4 +58,22 @@ public class BillingController {
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
return billingService.resume(current.getId()); 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)); 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 @Transactional
public SubscriptionResponse cancel(Long memberId) { public SubscriptionResponse cancel(Long memberId) {
@@ -147,6 +172,26 @@ public class BillingService {
.build(); .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) { private Member mustFind(Long memberId) {
Member m = memberMapper.findById(memberId); Member m = memberMapper.findById(memberId);
if (m == null) { if (m == null) {
@@ -14,4 +14,7 @@ public interface IapPurchaseMapper {
/** 구매 토큰으로 기존 처리 여부 조회 (중복 결제 방지) */ /** 구매 토큰으로 기존 처리 여부 조회 (중복 결제 방지) */
IapPurchase findByToken(@Param("purchaseToken") String purchaseToken); 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", .addPathPatterns("/api/auth/me", "/api/auth/points", "/api/auth/point-history",
"/api/auth/logout", "/api/auth/password", "/api/auth/logout", "/api/auth/password",
"/api/auth/verify-password", "/api/auth/profile", "/api/auth/profile-image", "/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 검사) // 인가: 관리자 전용 경로 (authInterceptor 다음에 실행되어 role 검사)
registry.addInterceptor(adminInterceptor) registry.addInterceptor(adminInterceptor)
.addPathPatterns("/api/admin/**"); .addPathPatterns("/api/admin/**");
@@ -26,4 +26,12 @@
FROM iap_purchase WHERE purchase_token = #{purchaseToken} FROM iap_purchase WHERE purchase_token = #{purchaseToken}
</select> </select>
<select id="findLatestByMember" parameterType="long" resultMap="iapResultMap">
SELECT id, member_id, platform, product_id, purchase_token, order_id, status, expires_at, created_at
FROM iap_purchase
WHERE member_id = #{memberId} AND status = 'VERIFIED'
ORDER BY expires_at DESC, id DESC
LIMIT 1
</select>
</mapper> </mapper>
+3 -2
View File
@@ -117,10 +117,11 @@
UPDATE member SET plan_auto_renew = #{autoRenew}, updated_at = NOW() WHERE id = #{id} UPDATE member SET plan_auto_renew = #{autoRenew}, updated_at = NOW() WHERE id = #{id}
</update> </update>
<!-- 만료된 유료회원 자동 강등 (스케줄러) — 구독정보도 정리 --> <!-- 만료된 '해지(자동갱신 off)' 유료회원만 강등 (자동갱신 건은 마켓 갱신/RTDN 이 관리) -->
<update id="downgradeExpired"> <update id="downgradeExpired">
UPDATE member SET plan = 'FREE', plan_product = NULL, plan_auto_renew = 0, updated_at = NOW() 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 &lt; NOW() WHERE plan = 'PREMIUM' AND plan_auto_renew = 0
AND plan_expires_at IS NOT NULL AND plan_expires_at &lt; NOW()
</update> </update>
<update id="updateStatus"> <update id="updateStatus">