feat: 인앱 결제(Google Play) 스캐폴드 + 멤버십 만료 처리
CI / build (push) Failing after 13m46s

- member.plan_expires_at + iap_purchase 테이블(중복 결제 방지)
- POST /api/billing/google/verify: 구매 검증 후 PREMIUM 부여/갱신(만료 연장), 세션 plan 즉시 동기화
- GET /api/billing/products: 구독 상품(월간/연간)
- 테스트 모드(billing.test-mode=true): test- 토큰 검증 없이 승인 → 전 과정 테스트 가능
- GooglePlayVerifier 인터페이스 + 스텁(실연동 지점), 실연동 시 구현체 교체
- 관리자 수동 plan 변경은 만료일 없이(영구), 결제는 만료일 부여
- 만료 자동 강등 스케줄러(매시), MemberResponse.planExpiresAt 노출

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-06-28 15:12:45 +09:00
parent d1c13e7cc1
commit 1e93280140
21 changed files with 421 additions and 4 deletions
@@ -7,7 +7,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
@MapperScan({"com.sb.web.user.mapper", "com.sb.web.auth.mapper", "com.sb.web.board.mapper", "com.sb.web.account.mapper", "com.sb.web.admin.mapper", "com.sb.web.point.mapper"})
@MapperScan({"com.sb.web.user.mapper", "com.sb.web.auth.mapper", "com.sb.web.board.mapper", "com.sb.web.account.mapper", "com.sb.web.admin.mapper", "com.sb.web.point.mapper", "com.sb.web.billing.mapper"})
public class SbBtApplication {
public static void main(String[] args) {
@@ -30,6 +30,7 @@ public class Member implements Serializable {
private String profileImage; // 사용자 지정 프로필 이미지(data URL). 우선순위: profileImage > googlePicture > 이니셜
private String role; // USER / ADMIN
private String plan; // FREE / PREMIUM (멤버십)
private LocalDateTime planExpiresAt; // 유료 멤버십 만료일 (NULL=만료없음/무료)
private Long points; // 활동 포인트 (게시판 글/댓글 작성 보상)
private String status; // ACTIVE / SUSPENDED / WITHDRAWN
private LocalDateTime createdAt;
@@ -18,6 +18,7 @@ public class MemberResponse {
private String provider;
private String role;
private String plan; // FREE / PREMIUM
private java.time.LocalDateTime planExpiresAt; // 유료 멤버십 만료일
private Long points; // 활동 포인트
private String googlePicture; // 구글 아바타 URL
private String profileImage; // 사용자 지정 프로필 이미지(data URL)
@@ -31,6 +32,7 @@ public class MemberResponse {
.provider(m.getProvider())
.role(m.getRole())
.plan(m.getPlan())
.planExpiresAt(m.getPlanExpiresAt())
.points(m.getPoints())
.googlePicture(m.getGooglePicture())
.profileImage(m.getProfileImage())
@@ -21,6 +21,9 @@ public interface AuthSessionMapper {
/** 프로필 변경 시 백업 세션의 표시 이름 동기화 */
int updateName(@Param("token") String token, @Param("name") String name);
/** 멤버십 변경 시 백업 세션의 plan 동기화 */
int updatePlan(@Param("token") String token, @Param("plan") String plan);
int delete(@Param("token") String token);
int deleteExpired(@Param("now") LocalDateTime now);
@@ -50,6 +50,13 @@ public interface MemberMapper {
int updatePlan(@Param("id") Long id, @Param("plan") String plan);
/** 결제로 멤버십 부여/갱신 (plan + 만료일) */
int updateMembership(@Param("id") Long id, @Param("plan") String plan,
@Param("expiresAt") java.time.LocalDateTime expiresAt);
/** 만료된 유료회원을 FREE 로 강등 (스케줄러). 강등된 건수 반환 */
int downgradeExpired();
int updateStatus(@Param("id") Long id, @Param("status") String status);
int deleteById(@Param("id") Long id);
@@ -402,6 +402,32 @@ public class AuthService {
}
}
/** 결제/멤버십 변경 시 활성 세션(Redis + DB 백업)의 plan 을 즉시 맞춘다 (재로그인 없이 유료 기능 개방). */
public void syncSessionPlan(String token, String plan) {
if (token == null || token.isBlank()) {
return;
}
String key = SESSION_PREFIX + token;
try {
Object cached = redisTemplate.opsForValue().get(key);
if (cached instanceof SessionUser u) {
u.setPlan(plan);
Long ttl = redisTemplate.getExpire(key, java.util.concurrent.TimeUnit.SECONDS);
Duration remain = (ttl != null && ttl > 0)
? Duration.ofSeconds(ttl)
: (u.isRememberMe() ? REMEMBER_TTL : SESSION_TTL);
redisTemplate.opsForValue().set(key, u, remain);
}
} catch (Exception e) {
log.warn("[billing] Redis 세션 plan 동기화 실패(무시): {}", e.toString());
}
try {
authSessionMapper.updatePlan(token, plan);
} catch (Exception ignore) {
// DB 백업 동기화 실패해도 다음 로그인 시 갱신됨
}
}
/**
* 비밀번호 변경 (본인). 현재 비밀번호 검증 후 새 비밀번호로 교체.
*/
@@ -0,0 +1,39 @@
package com.sb.web.billing;
import com.sb.web.auth.dto.SessionUser;
import com.sb.web.auth.web.AuthInterceptor;
import com.sb.web.billing.dto.MembershipResponse;
import com.sb.web.billing.dto.ProductResponse;
import com.sb.web.billing.dto.VerifyRequest;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 인앱 결제 API. (/api/billing/** — 로그인 필요)
* GET /products 구독 상품 목록
* POST /google/verify Google Play 구매 검증 → 멤버십 부여
*/
@RestController
@RequestMapping("/api/billing")
@RequiredArgsConstructor
public class BillingController {
private final BillingService billingService;
@GetMapping("/products")
public List<ProductResponse> products() {
return billingService.products();
}
@PostMapping("/google/verify")
public MembershipResponse verifyGoogle(
@Valid @RequestBody VerifyRequest req,
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current,
HttpServletRequest request) {
return billingService.verifyGoogle(current.getId(), AuthInterceptor.resolveToken(request), req);
}
}
@@ -0,0 +1,111 @@
package com.sb.web.billing;
import com.sb.web.auth.domain.Member;
import com.sb.web.auth.mapper.MemberMapper;
import com.sb.web.auth.service.AuthService;
import com.sb.web.billing.domain.IapPurchase;
import com.sb.web.billing.dto.MembershipResponse;
import com.sb.web.billing.dto.ProductResponse;
import com.sb.web.billing.dto.VerifyRequest;
import com.sb.web.billing.mapper.IapPurchaseMapper;
import com.sb.web.common.exception.ApiException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 인앱 결제(Google Play) 처리 — 상품 목록, 구매 검증 후 멤버십 부여.
* 스캐폴드: billing.test-mode=true 면 'test-' 토큰을 실제 검증 없이 승인한다.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class BillingService {
private final MemberMapper memberMapper;
private final IapPurchaseMapper purchaseMapper;
private final GooglePlayVerifier googlePlayVerifier;
private final AuthService authService;
/** 실연동 전 기본 true — 'test-' 토큰으로 결제 흐름 테스트 가능 */
@Value("${billing.test-mode:true}")
private boolean testMode;
/** 구독 상품 카탈로그 (가격은 표시용 — 실제 금액은 Play Console 기준) */
private static final Map<String, ProductResponse> CATALOG = new LinkedHashMap<>();
static {
CATALOG.put("premium_monthly", new ProductResponse("premium_monthly", "프리미엄 월간", 1, 2900));
CATALOG.put("premium_yearly", new ProductResponse("premium_yearly", "프리미엄 연간", 12, 29000));
}
public List<ProductResponse> products() {
return List.copyOf(CATALOG.values());
}
/**
* Google Play 구매 검증 후 멤버십(PREMIUM) 부여/갱신.
* 같은 구매 토큰은 한 번만 처리(멱등). 세션 plan 도 즉시 동기화.
*/
@Transactional
public MembershipResponse verifyGoogle(Long memberId, String sessionToken, VerifyRequest req) {
ProductResponse product = CATALOG.get(req.getProductId());
if (product == null) {
throw new ApiException(HttpStatus.BAD_REQUEST, "알 수 없는 상품입니다.");
}
String token = req.getPurchaseToken();
// 이미 처리된 결제면 현재 상태 반환 (중복 지급 방지)
IapPurchase existing = purchaseMapper.findByToken(token);
if (existing != null) {
return MembershipResponse.of(mustFind(memberId));
}
boolean test = testMode || token.startsWith("test-");
String platform;
if (test) {
platform = "TEST";
} else {
platform = "GOOGLE_PLAY";
if (!googlePlayVerifier.verify(req.getProductId(), token)) {
throw new ApiException(HttpStatus.BAD_REQUEST, "결제 검증에 실패했습니다.");
}
}
// 기존 만료일이 미래면 그 시점부터 연장, 아니면 지금부터
Member member = mustFind(memberId);
LocalDateTime now = LocalDateTime.now();
LocalDateTime base = (member.getPlanExpiresAt() != null && member.getPlanExpiresAt().isAfter(now))
? member.getPlanExpiresAt() : now;
LocalDateTime expires = base.plusMonths(product.getMonths());
memberMapper.updateMembership(memberId, "PREMIUM", expires);
purchaseMapper.insert(IapPurchase.builder()
.memberId(memberId).platform(platform).productId(req.getProductId())
.purchaseToken(token).orderId(req.getOrderId())
.status("VERIFIED").expiresAt(expires).build());
authService.syncSessionPlan(sessionToken, "PREMIUM");
log.info("[billing] PREMIUM granted member={} product={} platform={} expires={}",
memberId, req.getProductId(), platform, expires);
member.setPlan("PREMIUM");
member.setPlanExpiresAt(expires);
return MembershipResponse.of(member);
}
private Member mustFind(Long memberId) {
Member m = memberMapper.findById(memberId);
if (m == null) {
throw new ApiException(HttpStatus.NOT_FOUND, "회원을 찾을 수 없습니다.");
}
return m;
}
}
@@ -0,0 +1,11 @@
package com.sb.web.billing;
/**
* Google Play 구매 토큰 검증 추상화.
* 실연동(androidpublisher API + 서비스 계정) 구현체로 교체하면 된다.
*/
public interface GooglePlayVerifier {
/** 구매 토큰이 유효하면 true. 검증 미구성/실패 시 예외 또는 false. */
boolean verify(String productId, String purchaseToken);
}
@@ -0,0 +1,27 @@
package com.sb.web.billing;
import com.sb.web.auth.mapper.MemberMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* 만료된 유료 멤버십 자동 강등. 매시간 실행.
* (만료일이 NULL 인 관리자 영구 부여는 강등되지 않음)
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class PremiumExpiryScheduler {
private final MemberMapper memberMapper;
@Scheduled(cron = "0 5 * * * *") // 매시 5분
public void downgradeExpired() {
int n = memberMapper.downgradeExpired();
if (n > 0) {
log.info("[billing] 만료 멤버십 강등: {}건", n);
}
}
}
@@ -0,0 +1,19 @@
package com.sb.web.billing;
import com.sb.web.common.exception.ApiException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
/**
* 실연동 전 스텁. 실제 Google Play 검증은 아직 구성되지 않았다.
* (테스트 모드에서는 BillingService 가 이 검증을 건너뛴다.)
*/
@Component
public class StubGooglePlayVerifier implements GooglePlayVerifier {
@Override
public boolean verify(String productId, String purchaseToken) {
throw new ApiException(HttpStatus.SERVICE_UNAVAILABLE,
"구글 결제 검증이 아직 구성되지 않았습니다. (테스트 모드로만 결제 가능)");
}
}
@@ -0,0 +1,28 @@
package com.sb.web.billing.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 인앱 결제 기록 (iap_purchase).
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class IapPurchase {
private Long id;
private Long memberId;
private String platform; // GOOGLE_PLAY / TEST
private String productId;
private String purchaseToken;
private String orderId;
private String status; // VERIFIED / FAILED
private LocalDateTime expiresAt;
private LocalDateTime createdAt;
}
@@ -0,0 +1,24 @@
package com.sb.web.billing.dto;
import com.sb.web.auth.domain.Member;
import lombok.AllArgsConstructor;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 결제 후 멤버십 상태.
*/
@Data
@AllArgsConstructor
public class MembershipResponse {
private String plan; // FREE / PREMIUM
private LocalDateTime planExpiresAt; // 만료일 (NULL=만료없음)
private boolean premium;
public static MembershipResponse of(Member m) {
boolean premium = "PREMIUM".equals(m.getPlan());
return new MembershipResponse(m.getPlan(), m.getPlanExpiresAt(), premium);
}
}
@@ -0,0 +1,17 @@
package com.sb.web.billing.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
/**
* 구독 상품 (업그레이드 화면 표시용).
*/
@Data
@AllArgsConstructor
public class ProductResponse {
private String id; // Google Play 상품 ID
private String label; // 표시 이름
private int months; // 부여 개월수
private int priceKrw; // 표시용 가격(원) — 실제 결제 금액은 Play Console 기준(스캐폴드 표시용)
}
@@ -0,0 +1,20 @@
package com.sb.web.billing.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* Google Play 구매 검증 요청.
*/
@Data
public class VerifyRequest {
@NotBlank
private String productId;
/** Play 구매 토큰 (테스트는 'test-' 로 시작) */
@NotBlank
private String purchaseToken;
private String orderId;
}
@@ -0,0 +1,17 @@
package com.sb.web.billing.mapper;
import com.sb.web.billing.domain.IapPurchase;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
/**
* 인앱 결제 기록 매퍼.
*/
@Mapper
public interface IapPurchaseMapper {
int insert(IapPurchase purchase);
/** 구매 토큰으로 기존 처리 여부 조회 (중복 결제 방지) */
IapPurchase findByToken(@Param("purchaseToken") String purchaseToken);
}
@@ -28,7 +28,7 @@ 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/board/**", "/api/admin/**", "/api/account/**", "/api/billing/**");
// 인가: 관리자 전용 경로 (authInterceptor 다음에 실행되어 role 검사)
registry.addInterceptor(adminInterceptor)
.addPathPatterns("/api/admin/**");