@@ -7,7 +7,7 @@ import org.springframework.scheduling.annotation.EnableScheduling;
|
|||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
@EnableScheduling
|
@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 class SbBtApplication {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ public class Member implements Serializable {
|
|||||||
private String profileImage; // 사용자 지정 프로필 이미지(data URL). 우선순위: profileImage > googlePicture > 이니셜
|
private String profileImage; // 사용자 지정 프로필 이미지(data URL). 우선순위: profileImage > googlePicture > 이니셜
|
||||||
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 Long points; // 활동 포인트 (게시판 글/댓글 작성 보상)
|
private Long points; // 활동 포인트 (게시판 글/댓글 작성 보상)
|
||||||
private String status; // ACTIVE / SUSPENDED / WITHDRAWN
|
private String status; // ACTIVE / SUSPENDED / WITHDRAWN
|
||||||
private LocalDateTime createdAt;
|
private LocalDateTime createdAt;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ public class MemberResponse {
|
|||||||
private String provider;
|
private String provider;
|
||||||
private String role;
|
private String role;
|
||||||
private String plan; // FREE / PREMIUM
|
private String plan; // FREE / PREMIUM
|
||||||
|
private java.time.LocalDateTime planExpiresAt; // 유료 멤버십 만료일
|
||||||
private Long points; // 활동 포인트
|
private Long points; // 활동 포인트
|
||||||
private String googlePicture; // 구글 아바타 URL
|
private String googlePicture; // 구글 아바타 URL
|
||||||
private String profileImage; // 사용자 지정 프로필 이미지(data URL)
|
private String profileImage; // 사용자 지정 프로필 이미지(data URL)
|
||||||
@@ -31,6 +32,7 @@ public class MemberResponse {
|
|||||||
.provider(m.getProvider())
|
.provider(m.getProvider())
|
||||||
.role(m.getRole())
|
.role(m.getRole())
|
||||||
.plan(m.getPlan())
|
.plan(m.getPlan())
|
||||||
|
.planExpiresAt(m.getPlanExpiresAt())
|
||||||
.points(m.getPoints())
|
.points(m.getPoints())
|
||||||
.googlePicture(m.getGooglePicture())
|
.googlePicture(m.getGooglePicture())
|
||||||
.profileImage(m.getProfileImage())
|
.profileImage(m.getProfileImage())
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ public interface AuthSessionMapper {
|
|||||||
/** 프로필 변경 시 백업 세션의 표시 이름 동기화 */
|
/** 프로필 변경 시 백업 세션의 표시 이름 동기화 */
|
||||||
int updateName(@Param("token") String token, @Param("name") String name);
|
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 delete(@Param("token") String token);
|
||||||
|
|
||||||
int deleteExpired(@Param("now") LocalDateTime now);
|
int deleteExpired(@Param("now") LocalDateTime now);
|
||||||
|
|||||||
@@ -50,6 +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 + 만료일) */
|
||||||
|
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 updateStatus(@Param("id") Long id, @Param("status") String status);
|
||||||
|
|
||||||
int deleteById(@Param("id") Long id);
|
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",
|
.addPathPatterns("/api/auth/me", "/api/auth/points", "/api/auth/point-history",
|
||||||
"/api/auth/logout", "/api/auth/password",
|
"/api/auth/logout", "/api/auth/password",
|
||||||
"/api/auth/verify-password", "/api/auth/profile", "/api/auth/profile-image",
|
"/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 검사)
|
// 인가: 관리자 전용 경로 (authInterceptor 다음에 실행되어 role 검사)
|
||||||
registry.addInterceptor(adminInterceptor)
|
registry.addInterceptor(adminInterceptor)
|
||||||
.addPathPatterns("/api/admin/**");
|
.addPathPatterns("/api/admin/**");
|
||||||
|
|||||||
@@ -38,6 +38,25 @@ ALTER TABLE member ADD COLUMN IF NOT EXISTS profile_image MEDIUMTEXT NULL COMMEN
|
|||||||
-- 포인트(게시판 글/댓글 작성 보상). 기존 회원은 0 으로 시작 (멱등).
|
-- 포인트(게시판 글/댓글 작성 보상). 기존 회원은 0 으로 시작 (멱등).
|
||||||
ALTER TABLE member ADD COLUMN IF NOT EXISTS points BIGINT NOT NULL DEFAULT 0 COMMENT '활동 포인트' AFTER plan;
|
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 (
|
CREATE TABLE IF NOT EXISTS point_history (
|
||||||
id BIGINT NOT NULL AUTO_INCREMENT,
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
|||||||
@@ -27,6 +27,10 @@
|
|||||||
UPDATE auth_session SET name = #{name} WHERE token = #{token}
|
UPDATE auth_session SET name = #{name} WHERE token = #{token}
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
|
<update id="updatePlan">
|
||||||
|
UPDATE auth_session SET plan = #{plan} WHERE token = #{token}
|
||||||
|
</update>
|
||||||
|
|
||||||
<delete id="delete">
|
<delete id="delete">
|
||||||
DELETE FROM auth_session WHERE token = #{token}
|
DELETE FROM auth_session WHERE token = #{token}
|
||||||
</delete>
|
</delete>
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||||
|
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="com.sb.web.billing.mapper.IapPurchaseMapper">
|
||||||
|
|
||||||
|
<resultMap id="iapResultMap" type="com.sb.web.billing.domain.IapPurchase">
|
||||||
|
<id property="id" column="id"/>
|
||||||
|
<result property="memberId" column="member_id"/>
|
||||||
|
<result property="platform" column="platform"/>
|
||||||
|
<result property="productId" column="product_id"/>
|
||||||
|
<result property="purchaseToken" column="purchase_token"/>
|
||||||
|
<result property="orderId" column="order_id"/>
|
||||||
|
<result property="status" column="status"/>
|
||||||
|
<result property="expiresAt" column="expires_at"/>
|
||||||
|
<result property="createdAt" column="created_at"/>
|
||||||
|
</resultMap>
|
||||||
|
|
||||||
|
<insert id="insert" parameterType="com.sb.web.billing.domain.IapPurchase"
|
||||||
|
useGeneratedKeys="true" keyProperty="id">
|
||||||
|
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())
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<select id="findByToken" resultMap="iapResultMap">
|
||||||
|
SELECT id, member_id, platform, product_id, purchase_token, order_id, status, expires_at, created_at
|
||||||
|
FROM iap_purchase WHERE purchase_token = #{purchaseToken}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
<result property="profileImage" column="profile_image"/>
|
<result property="profileImage" column="profile_image"/>
|
||||||
<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="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"/>
|
||||||
@@ -23,7 +24,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, 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
|
||||||
</sql>
|
</sql>
|
||||||
|
|
||||||
<select id="findById" parameterType="long" resultMap="memberResultMap">
|
<select id="findById" parameterType="long" resultMap="memberResultMap">
|
||||||
@@ -96,8 +97,20 @@
|
|||||||
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}, updated_at = NOW() WHERE id = #{id}
|
UPDATE member SET plan = #{plan}, plan_expires_at = NULL, updated_at = NOW() WHERE id = #{id}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<!-- 결제로 멤버십 부여/갱신: plan + 만료일 -->
|
||||||
|
<update id="updateMembership">
|
||||||
|
UPDATE member SET plan = #{plan}, plan_expires_at = #{expiresAt}, updated_at = NOW() WHERE id = #{id}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<!-- 만료된 유료회원 자동 강등 (스케줄러) -->
|
||||||
|
<update id="downgradeExpired">
|
||||||
|
UPDATE member SET plan = 'FREE', updated_at = NOW()
|
||||||
|
WHERE plan = 'PREMIUM' AND plan_expires_at IS NOT NULL AND plan_expires_at < NOW()
|
||||||
</update>
|
</update>
|
||||||
|
|
||||||
<update id="updateStatus">
|
<update id="updateStatus">
|
||||||
|
|||||||
Reference in New Issue
Block a user