Merge branch 'dev'
Deploy / deploy (push) Failing after 15m19s

This commit is contained in:
ByungCheol
2026-06-28 16:04:22 +09:00
8 changed files with 123 additions and 10 deletions
@@ -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;
@@ -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())
@@ -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();
@@ -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());
}
}
@@ -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) {
@@ -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)
}
+4
View File
@@ -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,
+17 -7
View File
@@ -17,6 +17,8 @@
<result property="role" column="role"/>
<result property="plan" column="plan"/>
<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="status" column="status"/>
<result property="createdAt" column="created_at"/>
@@ -24,7 +26,7 @@
</resultMap>
<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>
<select id="findById" parameterType="long" resultMap="memberResultMap">
@@ -97,19 +99,27 @@
UPDATE member SET role = #{role}, updated_at = NOW() WHERE id = #{id}
</update>
<!-- 관리자 수동 변경: 만료일 없이(영구) 설정/해제 -->
<!-- 관리자 수동 변경: 만료일/구독정보 없이(영구) 설정/해제 -->
<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>
<!-- 결제로 멤버십 부여/갱신: plan + 만료일 -->
<!-- 결제로 멤버십 부여/갱신: plan + 만료일 + 구독상품 + 자동갱신 -->
<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 id="updateAutoRenew">
UPDATE member SET plan_auto_renew = #{autoRenew}, updated_at = NOW() WHERE id = #{id}
</update>
<!-- 만료된 유료회원 자동 강등 (스케줄러) — 구독정보도 정리 -->
<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 &lt; NOW()
</update>