@@ -31,6 +31,8 @@ public class Member implements Serializable {
|
|||||||
private String role; // USER / ADMIN
|
private String role; // USER / ADMIN
|
||||||
private String plan; // FREE / PREMIUM (멤버십)
|
private String plan; // FREE / PREMIUM (멤버십)
|
||||||
private LocalDateTime planExpiresAt; // 유료 멤버십 만료일 (NULL=만료없음/무료)
|
private LocalDateTime planExpiresAt; // 유료 멤버십 만료일 (NULL=만료없음/무료)
|
||||||
|
private String planProduct; // 구독 상품 ID (premium_monthly 등)
|
||||||
|
private Boolean planAutoRenew; // 자동 갱신 여부 (해지 시 false)
|
||||||
private Long points; // 활동 포인트 (게시판 글/댓글 작성 보상)
|
private Long points; // 활동 포인트 (게시판 글/댓글 작성 보상)
|
||||||
private String status; // ACTIVE / SUSPENDED / WITHDRAWN
|
private String status; // ACTIVE / SUSPENDED / WITHDRAWN
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ public class MemberResponse {
|
|||||||
private String role;
|
private String role;
|
||||||
private String plan; // FREE / PREMIUM
|
private String plan; // FREE / PREMIUM
|
||||||
private java.time.LocalDateTime planExpiresAt; // 유료 멤버십 만료일
|
private java.time.LocalDateTime planExpiresAt; // 유료 멤버십 만료일
|
||||||
|
private String planProduct; // 구독 상품 ID
|
||||||
|
private Boolean planAutoRenew; // 자동 갱신 여부
|
||||||
private Long points; // 활동 포인트
|
private Long points; // 활동 포인트
|
||||||
private String googlePicture; // 구글 아바타 URL
|
private String googlePicture; // 구글 아바타 URL
|
||||||
private String profileImage; // 사용자 지정 프로필 이미지(data URL)
|
private String profileImage; // 사용자 지정 프로필 이미지(data URL)
|
||||||
@@ -33,6 +35,8 @@ public class MemberResponse {
|
|||||||
.role(m.getRole())
|
.role(m.getRole())
|
||||||
.plan(m.getPlan())
|
.plan(m.getPlan())
|
||||||
.planExpiresAt(m.getPlanExpiresAt())
|
.planExpiresAt(m.getPlanExpiresAt())
|
||||||
|
.planProduct(m.getPlanProduct())
|
||||||
|
.planAutoRenew(m.getPlanAutoRenew())
|
||||||
.points(m.getPoints())
|
.points(m.getPoints())
|
||||||
.googlePicture(m.getGooglePicture())
|
.googlePicture(m.getGooglePicture())
|
||||||
.profileImage(m.getProfileImage())
|
.profileImage(m.getProfileImage())
|
||||||
|
|||||||
@@ -50,9 +50,13 @@ public interface MemberMapper {
|
|||||||
|
|
||||||
int updatePlan(@Param("id") Long id, @Param("plan") String plan);
|
int updatePlan(@Param("id") Long id, @Param("plan") String plan);
|
||||||
|
|
||||||
/** 결제로 멤버십 부여/갱신 (plan + 만료일) */
|
/** 결제로 멤버십 부여/갱신 (plan + 만료일 + 구독상품 + 자동갱신) */
|
||||||
int updateMembership(@Param("id") Long id, @Param("plan") String 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 로 강등 (스케줄러). 강등된 건수 반환 */
|
/** 만료된 유료회원을 FREE 로 강등 (스케줄러). 강등된 건수 반환 */
|
||||||
int downgradeExpired();
|
int downgradeExpired();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import com.sb.web.auth.dto.SessionUser;
|
|||||||
import com.sb.web.auth.web.AuthInterceptor;
|
import com.sb.web.auth.web.AuthInterceptor;
|
||||||
import com.sb.web.billing.dto.MembershipResponse;
|
import com.sb.web.billing.dto.MembershipResponse;
|
||||||
import com.sb.web.billing.dto.ProductResponse;
|
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.dto.VerifyRequest;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
@@ -36,4 +37,25 @@ public class BillingController {
|
|||||||
HttpServletRequest request) {
|
HttpServletRequest request) {
|
||||||
return billingService.verifyGoogle(current.getId(), AuthInterceptor.resolveToken(request), req);
|
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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.sb.web.auth.service.AuthService;
|
|||||||
import com.sb.web.billing.domain.IapPurchase;
|
import com.sb.web.billing.domain.IapPurchase;
|
||||||
import com.sb.web.billing.dto.MembershipResponse;
|
import com.sb.web.billing.dto.MembershipResponse;
|
||||||
import com.sb.web.billing.dto.ProductResponse;
|
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.dto.VerifyRequest;
|
||||||
import com.sb.web.billing.mapper.IapPurchaseMapper;
|
import com.sb.web.billing.mapper.IapPurchaseMapper;
|
||||||
import com.sb.web.common.exception.ApiException;
|
import com.sb.web.common.exception.ApiException;
|
||||||
@@ -86,7 +87,7 @@ public class BillingService {
|
|||||||
? member.getPlanExpiresAt() : now;
|
? member.getPlanExpiresAt() : now;
|
||||||
LocalDateTime expires = base.plusMonths(product.getMonths());
|
LocalDateTime expires = base.plusMonths(product.getMonths());
|
||||||
|
|
||||||
memberMapper.updateMembership(memberId, "PREMIUM", expires);
|
memberMapper.updateMembership(memberId, "PREMIUM", expires, req.getProductId(), true);
|
||||||
purchaseMapper.insert(IapPurchase.builder()
|
purchaseMapper.insert(IapPurchase.builder()
|
||||||
.memberId(memberId).platform(platform).productId(req.getProductId())
|
.memberId(memberId).platform(platform).productId(req.getProductId())
|
||||||
.purchaseToken(token).orderId(req.getOrderId())
|
.purchaseToken(token).orderId(req.getOrderId())
|
||||||
@@ -101,6 +102,51 @@ public class BillingService {
|
|||||||
return MembershipResponse.of(member);
|
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) {
|
private Member mustFind(Long memberId) {
|
||||||
Member m = memberMapper.findById(memberId);
|
Member m = memberMapper.findById(memberId);
|
||||||
if (m == null) {
|
if (m == null) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -41,6 +41,10 @@ ALTER TABLE member ADD COLUMN IF NOT EXISTS points BIGINT NOT NULL DEFAULT 0 COM
|
|||||||
-- 멤버십 만료일 (유료 구독). NULL = 만료 없음(관리자 영구 부여) 또는 무료. 만료 지나면 스케줄러가 FREE 로 강등.
|
-- 멤버십 만료일 (유료 구독). NULL = 만료 없음(관리자 영구 부여) 또는 무료. 만료 지나면 스케줄러가 FREE 로 강등.
|
||||||
ALTER TABLE member ADD COLUMN IF NOT EXISTS plan_expires_at DATETIME NULL COMMENT '유료 멤버십 만료일시' AFTER plan;
|
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 (
|
CREATE TABLE IF NOT EXISTS iap_purchase (
|
||||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
<result property="role" column="role"/>
|
<result property="role" column="role"/>
|
||||||
<result property="plan" column="plan"/>
|
<result property="plan" column="plan"/>
|
||||||
<result property="planExpiresAt" column="plan_expires_at"/>
|
<result property="planExpiresAt" column="plan_expires_at"/>
|
||||||
|
<result property="planProduct" column="plan_product"/>
|
||||||
|
<result property="planAutoRenew" column="plan_auto_renew"/>
|
||||||
<result property="points" column="points"/>
|
<result property="points" column="points"/>
|
||||||
<result property="status" column="status"/>
|
<result property="status" column="status"/>
|
||||||
<result property="createdAt" column="created_at"/>
|
<result property="createdAt" column="created_at"/>
|
||||||
@@ -24,7 +26,7 @@
|
|||||||
</resultMap>
|
</resultMap>
|
||||||
|
|
||||||
<sql id="columns">
|
<sql id="columns">
|
||||||
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
|
||||||
</sql>
|
</sql>
|
||||||
|
|
||||||
<select id="findById" parameterType="long" resultMap="memberResultMap">
|
<select id="findById" parameterType="long" resultMap="memberResultMap">
|
||||||
@@ -97,19 +99,27 @@
|
|||||||
UPDATE member SET role = #{role}, updated_at = NOW() WHERE id = #{id}
|
UPDATE member SET role = #{role}, updated_at = NOW() WHERE id = #{id}
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
<!-- 관리자 수동 변경: 만료일 없이(영구) 설정/해제 -->
|
<!-- 관리자 수동 변경: 만료일/구독정보 없이(영구) 설정/해제 -->
|
||||||
<update id="updatePlan">
|
<update id="updatePlan">
|
||||||
UPDATE member SET plan = #{plan}, plan_expires_at = NULL, updated_at = NOW() WHERE id = #{id}
|
UPDATE member SET plan = #{plan}, plan_expires_at = NULL, plan_product = NULL, plan_auto_renew = 0,
|
||||||
|
updated_at = NOW() WHERE id = #{id}
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
<!-- 결제로 멤버십 부여/갱신: plan + 만료일 -->
|
<!-- 결제로 멤버십 부여/갱신: plan + 만료일 + 구독상품 + 자동갱신 -->
|
||||||
<update id="updateMembership">
|
<update id="updateMembership">
|
||||||
UPDATE member SET plan = #{plan}, plan_expires_at = #{expiresAt}, updated_at = NOW() WHERE id = #{id}
|
UPDATE member SET plan = #{plan}, plan_expires_at = #{expiresAt},
|
||||||
|
plan_product = #{product}, plan_auto_renew = #{autoRenew}, updated_at = NOW()
|
||||||
|
WHERE id = #{id}
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
<!-- 만료된 유료회원 자동 강등 (스케줄러) -->
|
<!-- 구독 해지/재개: 자동 갱신 플래그만 변경 (이용은 만료일까지 유지) -->
|
||||||
|
<update id="updateAutoRenew">
|
||||||
|
UPDATE member SET plan_auto_renew = #{autoRenew}, updated_at = NOW() WHERE id = #{id}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<!-- 만료된 유료회원 자동 강등 (스케줄러) — 구독정보도 정리 -->
|
||||||
<update id="downgradeExpired">
|
<update id="downgradeExpired">
|
||||||
UPDATE member SET plan = 'FREE', 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 < NOW()
|
WHERE plan = 'PREMIUM' AND plan_expires_at IS NOT NULL AND plan_expires_at < NOW()
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user