- 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:
@@ -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/**");
|
||||
|
||||
Reference in New Issue
Block a user