diff --git a/src/main/java/com/sb/web/SbBtApplication.java b/src/main/java/com/sb/web/SbBtApplication.java index fece9d5..0d77bac 100644 --- a/src/main/java/com/sb/web/SbBtApplication.java +++ b/src/main/java/com/sb/web/SbBtApplication.java @@ -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) { diff --git a/src/main/java/com/sb/web/auth/domain/Member.java b/src/main/java/com/sb/web/auth/domain/Member.java index 98b79df..220e3ac 100644 --- a/src/main/java/com/sb/web/auth/domain/Member.java +++ b/src/main/java/com/sb/web/auth/domain/Member.java @@ -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; diff --git a/src/main/java/com/sb/web/auth/dto/MemberResponse.java b/src/main/java/com/sb/web/auth/dto/MemberResponse.java index d8f48da..fc3ecd3 100644 --- a/src/main/java/com/sb/web/auth/dto/MemberResponse.java +++ b/src/main/java/com/sb/web/auth/dto/MemberResponse.java @@ -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()) diff --git a/src/main/java/com/sb/web/auth/mapper/AuthSessionMapper.java b/src/main/java/com/sb/web/auth/mapper/AuthSessionMapper.java index 7e5a4e2..29157b6 100644 --- a/src/main/java/com/sb/web/auth/mapper/AuthSessionMapper.java +++ b/src/main/java/com/sb/web/auth/mapper/AuthSessionMapper.java @@ -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); diff --git a/src/main/java/com/sb/web/auth/mapper/MemberMapper.java b/src/main/java/com/sb/web/auth/mapper/MemberMapper.java index 74d3705..df719c6 100644 --- a/src/main/java/com/sb/web/auth/mapper/MemberMapper.java +++ b/src/main/java/com/sb/web/auth/mapper/MemberMapper.java @@ -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); diff --git a/src/main/java/com/sb/web/auth/service/AuthService.java b/src/main/java/com/sb/web/auth/service/AuthService.java index 9a614a4..97976e4 100644 --- a/src/main/java/com/sb/web/auth/service/AuthService.java +++ b/src/main/java/com/sb/web/auth/service/AuthService.java @@ -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 백업 동기화 실패해도 다음 로그인 시 갱신됨 + } + } + /** * 비밀번호 변경 (본인). 현재 비밀번호 검증 후 새 비밀번호로 교체. */ diff --git a/src/main/java/com/sb/web/billing/BillingController.java b/src/main/java/com/sb/web/billing/BillingController.java new file mode 100644 index 0000000..761c7e7 --- /dev/null +++ b/src/main/java/com/sb/web/billing/BillingController.java @@ -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 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); + } +} diff --git a/src/main/java/com/sb/web/billing/BillingService.java b/src/main/java/com/sb/web/billing/BillingService.java new file mode 100644 index 0000000..e61ba18 --- /dev/null +++ b/src/main/java/com/sb/web/billing/BillingService.java @@ -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 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 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; + } +} diff --git a/src/main/java/com/sb/web/billing/GooglePlayVerifier.java b/src/main/java/com/sb/web/billing/GooglePlayVerifier.java new file mode 100644 index 0000000..2633e29 --- /dev/null +++ b/src/main/java/com/sb/web/billing/GooglePlayVerifier.java @@ -0,0 +1,11 @@ +package com.sb.web.billing; + +/** + * Google Play 구매 토큰 검증 추상화. + * 실연동(androidpublisher API + 서비스 계정) 구현체로 교체하면 된다. + */ +public interface GooglePlayVerifier { + + /** 구매 토큰이 유효하면 true. 검증 미구성/실패 시 예외 또는 false. */ + boolean verify(String productId, String purchaseToken); +} diff --git a/src/main/java/com/sb/web/billing/PremiumExpiryScheduler.java b/src/main/java/com/sb/web/billing/PremiumExpiryScheduler.java new file mode 100644 index 0000000..863fa09 --- /dev/null +++ b/src/main/java/com/sb/web/billing/PremiumExpiryScheduler.java @@ -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); + } + } +} diff --git a/src/main/java/com/sb/web/billing/StubGooglePlayVerifier.java b/src/main/java/com/sb/web/billing/StubGooglePlayVerifier.java new file mode 100644 index 0000000..83b179a --- /dev/null +++ b/src/main/java/com/sb/web/billing/StubGooglePlayVerifier.java @@ -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, + "구글 결제 검증이 아직 구성되지 않았습니다. (테스트 모드로만 결제 가능)"); + } +} diff --git a/src/main/java/com/sb/web/billing/domain/IapPurchase.java b/src/main/java/com/sb/web/billing/domain/IapPurchase.java new file mode 100644 index 0000000..006938c --- /dev/null +++ b/src/main/java/com/sb/web/billing/domain/IapPurchase.java @@ -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; +} diff --git a/src/main/java/com/sb/web/billing/dto/MembershipResponse.java b/src/main/java/com/sb/web/billing/dto/MembershipResponse.java new file mode 100644 index 0000000..8789071 --- /dev/null +++ b/src/main/java/com/sb/web/billing/dto/MembershipResponse.java @@ -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); + } +} diff --git a/src/main/java/com/sb/web/billing/dto/ProductResponse.java b/src/main/java/com/sb/web/billing/dto/ProductResponse.java new file mode 100644 index 0000000..d016244 --- /dev/null +++ b/src/main/java/com/sb/web/billing/dto/ProductResponse.java @@ -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 기준(스캐폴드 표시용) +} diff --git a/src/main/java/com/sb/web/billing/dto/VerifyRequest.java b/src/main/java/com/sb/web/billing/dto/VerifyRequest.java new file mode 100644 index 0000000..8109bdb --- /dev/null +++ b/src/main/java/com/sb/web/billing/dto/VerifyRequest.java @@ -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; +} diff --git a/src/main/java/com/sb/web/billing/mapper/IapPurchaseMapper.java b/src/main/java/com/sb/web/billing/mapper/IapPurchaseMapper.java new file mode 100644 index 0000000..5e7a6e6 --- /dev/null +++ b/src/main/java/com/sb/web/billing/mapper/IapPurchaseMapper.java @@ -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); +} 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 3eff788..7cb219f 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,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/**"); diff --git a/src/main/resources/db/member.sql b/src/main/resources/db/member.sql index a423668..4163057 100644 --- a/src/main/resources/db/member.sql +++ b/src/main/resources/db/member.sql @@ -38,6 +38,25 @@ ALTER TABLE member ADD COLUMN IF NOT EXISTS profile_image MEDIUMTEXT NULL COMMEN -- 포인트(게시판 글/댓글 작성 보상). 기존 회원은 0 으로 시작 (멱등). ALTER TABLE member ADD COLUMN IF NOT EXISTS points BIGINT NOT NULL DEFAULT 0 COMMENT '활동 포인트' AFTER plan; +-- 멤버십 만료일 (유료 구독). NULL = 만료 없음(관리자 영구 부여) 또는 무료. 만료 지나면 스케줄러가 FREE 로 강등. +ALTER TABLE member ADD COLUMN IF NOT EXISTS plan_expires_at DATETIME NULL COMMENT '유료 멤버십 만료일시' AFTER plan; + +-- 인앱 결제 기록 (영수증 검증·중복 방지·감사) +CREATE TABLE IF NOT EXISTS iap_purchase ( + id BIGINT NOT NULL AUTO_INCREMENT, + member_id BIGINT NOT NULL, + platform VARCHAR(20) NOT NULL COMMENT 'GOOGLE_PLAY / TEST', + product_id VARCHAR(100) NOT NULL COMMENT '상품 ID(premium_monthly 등)', + purchase_token VARCHAR(512) NOT NULL COMMENT 'Play 구매 토큰(또는 테스트 토큰)', + order_id VARCHAR(120) NULL, + status VARCHAR(20) NOT NULL COMMENT 'VERIFIED / FAILED', + expires_at DATETIME NULL COMMENT '이 결제로 부여된 만료일', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_iap_token (purchase_token), + KEY idx_iap_member (member_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + -- 포인트 적립/차감 이력 (감사 + 일일 지급 한도 계산용). CREATE TABLE IF NOT EXISTS point_history ( id BIGINT NOT NULL AUTO_INCREMENT, diff --git a/src/main/resources/mapper/AuthSessionMapper.xml b/src/main/resources/mapper/AuthSessionMapper.xml index 94ff326..d1fea51 100644 --- a/src/main/resources/mapper/AuthSessionMapper.xml +++ b/src/main/resources/mapper/AuthSessionMapper.xml @@ -27,6 +27,10 @@ UPDATE auth_session SET name = #{name} WHERE token = #{token} + + UPDATE auth_session SET plan = #{plan} WHERE token = #{token} + + DELETE FROM auth_session WHERE token = #{token} diff --git a/src/main/resources/mapper/IapPurchaseMapper.xml b/src/main/resources/mapper/IapPurchaseMapper.xml new file mode 100644 index 0000000..c1e47d4 --- /dev/null +++ b/src/main/resources/mapper/IapPurchaseMapper.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + INSERT INTO iap_purchase (member_id, platform, product_id, purchase_token, order_id, status, expires_at, created_at) + VALUES (#{memberId}, #{platform}, #{productId}, #{purchaseToken}, #{orderId}, #{status}, #{expiresAt}, NOW()) + + + + + diff --git a/src/main/resources/mapper/MemberMapper.xml b/src/main/resources/mapper/MemberMapper.xml index 170789d..38d4cb4 100644 --- a/src/main/resources/mapper/MemberMapper.xml +++ b/src/main/resources/mapper/MemberMapper.xml @@ -16,6 +16,7 @@ + @@ -23,7 +24,7 @@ - id, login_id, password, name, email, provider, provider_id, google_id, google_picture, profile_image, role, plan, points, status, created_at, updated_at + id, login_id, password, name, email, provider, provider_id, google_id, google_picture, profile_image, role, plan, plan_expires_at, points, status, created_at, updated_at