From be15f5b85dd23d721e869bff5882d01991cc2acc Mon Sep 17 00:00:00 2001 From: ByungCheol Date: Sun, 28 Jun 2026 16:04:20 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=A0=95=EA=B8=B0=EA=B2=B0=EC=A0=9C=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=E2=80=94=20=EA=B5=AC=EB=8F=85=20=ED=98=84?= =?UTF-8?q?=ED=99=A9/=ED=95=B4=EC=A7=80/=EC=9E=AC=EA=B0=9C/=ED=94=8C?= =?UTF-8?q?=EB=9E=9C=EB=B3=80=EA=B2=BD=20=EB=B0=B1=EC=97=94=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - member.plan_product, plan_auto_renew 컬럼(구독 상품·자동갱신) - GET /api/billing/subscription: 플랜·만료일·다음결제일·자동갱신 - POST /api/billing/cancel: 자동갱신 중단(만료일까지 이용), /resume: 재개 - verify 시 상품·자동갱신 기록, 만료 강등·관리자 변경 시 구독정보 정리 Co-Authored-By: Claude Opus 4.8 --- .../java/com/sb/web/auth/domain/Member.java | 2 + .../com/sb/web/auth/dto/MemberResponse.java | 4 ++ .../com/sb/web/auth/mapper/MemberMapper.java | 8 +++- .../com/sb/web/billing/BillingController.java | 22 +++++++++ .../com/sb/web/billing/BillingService.java | 48 ++++++++++++++++++- .../web/billing/dto/SubscriptionResponse.java | 21 ++++++++ src/main/resources/db/member.sql | 4 ++ src/main/resources/mapper/MemberMapper.xml | 24 +++++++--- 8 files changed, 123 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/sb/web/billing/dto/SubscriptionResponse.java 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 220e3ac..6e0fd75 100644 --- a/src/main/java/com/sb/web/auth/domain/Member.java +++ b/src/main/java/com/sb/web/auth/domain/Member.java @@ -31,6 +31,8 @@ public class Member implements Serializable { private String role; // USER / ADMIN private String plan; // FREE / PREMIUM (멤버십) private LocalDateTime planExpiresAt; // 유료 멤버십 만료일 (NULL=만료없음/무료) + private String planProduct; // 구독 상품 ID (premium_monthly 등) + private Boolean planAutoRenew; // 자동 갱신 여부 (해지 시 false) 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 fc3ecd3..a37646b 100644 --- a/src/main/java/com/sb/web/auth/dto/MemberResponse.java +++ b/src/main/java/com/sb/web/auth/dto/MemberResponse.java @@ -19,6 +19,8 @@ public class MemberResponse { private String role; private String plan; // FREE / PREMIUM private java.time.LocalDateTime planExpiresAt; // 유료 멤버십 만료일 + private String planProduct; // 구독 상품 ID + private Boolean planAutoRenew; // 자동 갱신 여부 private Long points; // 활동 포인트 private String googlePicture; // 구글 아바타 URL private String profileImage; // 사용자 지정 프로필 이미지(data URL) @@ -33,6 +35,8 @@ public class MemberResponse { .role(m.getRole()) .plan(m.getPlan()) .planExpiresAt(m.getPlanExpiresAt()) + .planProduct(m.getPlanProduct()) + .planAutoRenew(m.getPlanAutoRenew()) .points(m.getPoints()) .googlePicture(m.getGooglePicture()) .profileImage(m.getProfileImage()) 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 df719c6..3f94235 100644 --- a/src/main/java/com/sb/web/auth/mapper/MemberMapper.java +++ b/src/main/java/com/sb/web/auth/mapper/MemberMapper.java @@ -50,9 +50,13 @@ public interface MemberMapper { int updatePlan(@Param("id") Long id, @Param("plan") String plan); - /** 결제로 멤버십 부여/갱신 (plan + 만료일) */ + /** 결제로 멤버십 부여/갱신 (plan + 만료일 + 구독상품 + 자동갱신) */ int updateMembership(@Param("id") Long id, @Param("plan") String plan, - @Param("expiresAt") java.time.LocalDateTime expiresAt); + @Param("expiresAt") java.time.LocalDateTime expiresAt, + @Param("product") String product, @Param("autoRenew") boolean autoRenew); + + /** 구독 해지/재개 (자동 갱신 플래그) */ + int updateAutoRenew(@Param("id") Long id, @Param("autoRenew") boolean autoRenew); /** 만료된 유료회원을 FREE 로 강등 (스케줄러). 강등된 건수 반환 */ int downgradeExpired(); diff --git a/src/main/java/com/sb/web/billing/BillingController.java b/src/main/java/com/sb/web/billing/BillingController.java index 761c7e7..b8c6152 100644 --- a/src/main/java/com/sb/web/billing/BillingController.java +++ b/src/main/java/com/sb/web/billing/BillingController.java @@ -4,6 +4,7 @@ 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.SubscriptionResponse; import com.sb.web.billing.dto.VerifyRequest; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; @@ -36,4 +37,25 @@ public class BillingController { HttpServletRequest request) { return billingService.verifyGoogle(current.getId(), AuthInterceptor.resolveToken(request), req); } + + /** 현재 구독 현황 (플랜·만료일·다음 결제일·자동갱신) */ + @GetMapping("/subscription") + public SubscriptionResponse subscription( + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return billingService.getSubscription(current.getId()); + } + + /** 구독 해지 (자동 갱신 중단, 만료일까지 이용) */ + @PostMapping("/cancel") + public SubscriptionResponse cancel( + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return billingService.cancel(current.getId()); + } + + /** 구독 재개 (만료 전 자동 갱신 재개) */ + @PostMapping("/resume") + public SubscriptionResponse resume( + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return billingService.resume(current.getId()); + } } diff --git a/src/main/java/com/sb/web/billing/BillingService.java b/src/main/java/com/sb/web/billing/BillingService.java index e61ba18..d2a78ff 100644 --- a/src/main/java/com/sb/web/billing/BillingService.java +++ b/src/main/java/com/sb/web/billing/BillingService.java @@ -6,6 +6,7 @@ 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.SubscriptionResponse; import com.sb.web.billing.dto.VerifyRequest; import com.sb.web.billing.mapper.IapPurchaseMapper; import com.sb.web.common.exception.ApiException; @@ -86,7 +87,7 @@ public class BillingService { ? member.getPlanExpiresAt() : now; LocalDateTime expires = base.plusMonths(product.getMonths()); - memberMapper.updateMembership(memberId, "PREMIUM", expires); + memberMapper.updateMembership(memberId, "PREMIUM", expires, req.getProductId(), true); purchaseMapper.insert(IapPurchase.builder() .memberId(memberId).platform(platform).productId(req.getProductId()) .purchaseToken(token).orderId(req.getOrderId()) @@ -101,6 +102,51 @@ public class BillingService { return MembershipResponse.of(member); } + /** 현재 구독 현황 */ + public SubscriptionResponse getSubscription(Long memberId) { + return toSubscription(mustFind(memberId)); + } + + /** 구독 해지 — 자동 갱신만 끈다. 이용은 만료일까지 유지(이후 무료 전환). */ + @Transactional + public SubscriptionResponse cancel(Long memberId) { + Member m = mustFind(memberId); + if (!"PREMIUM".equals(m.getPlan())) { + throw new ApiException(HttpStatus.BAD_REQUEST, "구독 중이 아닙니다."); + } + memberMapper.updateAutoRenew(memberId, false); + m.setPlanAutoRenew(false); + log.info("[billing] subscription canceled member={} (이용 만료일까지 유지)", memberId); + return toSubscription(m); + } + + /** 구독 재개 — 만료 전이면 자동 갱신을 다시 켠다. */ + @Transactional + public SubscriptionResponse resume(Long memberId) { + Member m = mustFind(memberId); + if (!"PREMIUM".equals(m.getPlan())) { + throw new ApiException(HttpStatus.BAD_REQUEST, "구독 중이 아닙니다."); + } + memberMapper.updateAutoRenew(memberId, true); + m.setPlanAutoRenew(true); + return toSubscription(m); + } + + private SubscriptionResponse toSubscription(Member m) { + boolean premium = "PREMIUM".equals(m.getPlan()); + boolean autoRenew = premium && Boolean.TRUE.equals(m.getPlanAutoRenew()); + ProductResponse p = m.getPlanProduct() == null ? null : CATALOG.get(m.getPlanProduct()); + String label = p != null ? p.getLabel() : (premium ? "프리미엄" : null); + return SubscriptionResponse.builder() + .premium(premium) + .planProduct(m.getPlanProduct()) + .planLabel(label) + .expiresAt(m.getPlanExpiresAt()) + .autoRenew(autoRenew) + .nextBillingAt(autoRenew ? m.getPlanExpiresAt() : null) + .build(); + } + private Member mustFind(Long memberId) { Member m = memberMapper.findById(memberId); if (m == null) { diff --git a/src/main/java/com/sb/web/billing/dto/SubscriptionResponse.java b/src/main/java/com/sb/web/billing/dto/SubscriptionResponse.java new file mode 100644 index 0000000..d48b2ff --- /dev/null +++ b/src/main/java/com/sb/web/billing/dto/SubscriptionResponse.java @@ -0,0 +1,21 @@ +package com.sb.web.billing.dto; + +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 정기결제(구독) 현황 — 계정정보 화면 표시용. + */ +@Data +@Builder +public class SubscriptionResponse { + + private boolean premium; // 현재 유료 여부 + private String planProduct; // 구독 상품 ID + private String planLabel; // 상품 표시 이름 (프리미엄 월간 등) + private LocalDateTime expiresAt; // 이용 만료일 + private boolean autoRenew; // 자동 갱신 여부 + private LocalDateTime nextBillingAt; // 다음 결제일 (자동 갱신 시 = 만료일, 해지 시 null) +} diff --git a/src/main/resources/db/member.sql b/src/main/resources/db/member.sql index 4163057..6a5eb26 100644 --- a/src/main/resources/db/member.sql +++ b/src/main/resources/db/member.sql @@ -41,6 +41,10 @@ ALTER TABLE member ADD COLUMN IF NOT EXISTS points BIGINT NOT NULL DEFAULT 0 COM -- 멤버십 만료일 (유료 구독). NULL = 만료 없음(관리자 영구 부여) 또는 무료. 만료 지나면 스케줄러가 FREE 로 강등. ALTER TABLE member ADD COLUMN IF NOT EXISTS plan_expires_at DATETIME NULL COMMENT '유료 멤버십 만료일시' AFTER plan; +-- 구독 상품(premium_monthly 등) + 자동 갱신 여부 (정기결제 관리) +ALTER TABLE member ADD COLUMN IF NOT EXISTS plan_product VARCHAR(100) NULL COMMENT '구독 상품 ID' AFTER plan_expires_at; +ALTER TABLE member ADD COLUMN IF NOT EXISTS plan_auto_renew TINYINT(1) NOT NULL DEFAULT 0 COMMENT '자동 갱신(해지 시 0)' AFTER plan_product; + -- 인앱 결제 기록 (영수증 검증·중복 방지·감사) CREATE TABLE IF NOT EXISTS iap_purchase ( id BIGINT NOT NULL AUTO_INCREMENT, diff --git a/src/main/resources/mapper/MemberMapper.xml b/src/main/resources/mapper/MemberMapper.xml index 38d4cb4..7e523f6 100644 --- a/src/main/resources/mapper/MemberMapper.xml +++ b/src/main/resources/mapper/MemberMapper.xml @@ -17,6 +17,8 @@ + + @@ -24,7 +26,7 @@ - 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 + id, login_id, password, name, email, provider, provider_id, google_id, google_picture, profile_image, role, plan, plan_expires_at, plan_product, plan_auto_renew, points, status, created_at, updated_at