diff --git a/src/main/java/com/sb/web/admin/controller/MemberAdminController.java b/src/main/java/com/sb/web/admin/controller/MemberAdminController.java
index 38eec53..440b5f6 100644
--- a/src/main/java/com/sb/web/admin/controller/MemberAdminController.java
+++ b/src/main/java/com/sb/web/admin/controller/MemberAdminController.java
@@ -1,5 +1,6 @@
package com.sb.web.admin.controller;
+import com.sb.web.admin.dto.PlanUpdateRequest;
import com.sb.web.admin.dto.RoleUpdateRequest;
import com.sb.web.admin.dto.StatusUpdateRequest;
import com.sb.web.admin.service.MemberAdminService;
@@ -36,6 +37,14 @@ public class MemberAdminController {
return memberAdminService.updateRole(id, req.getRole(), current.getId());
}
+ @PutMapping("/{id}/plan")
+ public MemberAdminResponse updatePlan(
+ @PathVariable Long id,
+ @Valid @RequestBody PlanUpdateRequest req,
+ @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
+ return memberAdminService.updatePlan(id, req.getPlan(), current.getId());
+ }
+
@PutMapping("/{id}/status")
public MemberAdminResponse updateStatus(
@PathVariable Long id,
diff --git a/src/main/java/com/sb/web/admin/dto/PlanUpdateRequest.java b/src/main/java/com/sb/web/admin/dto/PlanUpdateRequest.java
new file mode 100644
index 0000000..766a492
--- /dev/null
+++ b/src/main/java/com/sb/web/admin/dto/PlanUpdateRequest.java
@@ -0,0 +1,14 @@
+package com.sb.web.admin.dto;
+
+import jakarta.validation.constraints.Pattern;
+import lombok.Data;
+
+/**
+ * 회원 멤버십 플랜 변경 요청.
+ */
+@Data
+public class PlanUpdateRequest {
+
+ @Pattern(regexp = "FREE|PREMIUM", message = "플랜은 FREE 또는 PREMIUM 이어야 합니다.")
+ private String plan;
+}
diff --git a/src/main/java/com/sb/web/admin/service/MemberAdminService.java b/src/main/java/com/sb/web/admin/service/MemberAdminService.java
index 3831497..b53dd9b 100644
--- a/src/main/java/com/sb/web/admin/service/MemberAdminService.java
+++ b/src/main/java/com/sb/web/admin/service/MemberAdminService.java
@@ -36,6 +36,14 @@ public class MemberAdminService {
return MemberAdminResponse.from(target);
}
+ @Transactional
+ public MemberAdminResponse updatePlan(Long targetId, String plan, Long currentAdminId) {
+ Member target = mustFind(targetId);
+ memberMapper.updatePlan(targetId, plan);
+ target.setPlan(plan);
+ return MemberAdminResponse.from(target);
+ }
+
@Transactional
public MemberAdminResponse updateStatus(Long targetId, String status, Long currentAdminId) {
Member target = mustFind(targetId);
diff --git a/src/main/java/com/sb/web/auth/domain/AuthSession.java b/src/main/java/com/sb/web/auth/domain/AuthSession.java
index e3794cd..0ee0097 100644
--- a/src/main/java/com/sb/web/auth/domain/AuthSession.java
+++ b/src/main/java/com/sb/web/auth/domain/AuthSession.java
@@ -22,6 +22,7 @@ public class AuthSession {
private String loginId;
private String name;
private String role;
+ private String plan;
private String provider;
private boolean rememberMe;
private LocalDateTime expiresAt;
@@ -34,6 +35,7 @@ public class AuthSession {
.loginId(u.getLoginId())
.name(u.getName())
.role(u.getRole())
+ .plan(u.getPlan())
.provider(u.getProvider())
.rememberMe(u.isRememberMe())
.expiresAt(expiresAt)
@@ -46,6 +48,7 @@ public class AuthSession {
.loginId(loginId)
.name(name)
.role(role)
+ .plan(plan)
.provider(provider)
.rememberMe(rememberMe)
.build();
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 5a671c4..eec71c1 100644
--- a/src/main/java/com/sb/web/auth/domain/Member.java
+++ b/src/main/java/com/sb/web/auth/domain/Member.java
@@ -27,6 +27,7 @@ public class Member implements Serializable {
private String providerId; // 소셜 제공자 측 고유 ID
private String googleId; // 구글 sub (계정 연결용 — LOCAL 계정에 구글을 연결해도 provider 는 유지)
private String role; // USER / ADMIN
+ private String plan; // FREE / PREMIUM (멤버십)
private String status; // ACTIVE / SUSPENDED / WITHDRAWN
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
diff --git a/src/main/java/com/sb/web/auth/dto/MemberAdminResponse.java b/src/main/java/com/sb/web/auth/dto/MemberAdminResponse.java
index 16b7695..d836cdb 100644
--- a/src/main/java/com/sb/web/auth/dto/MemberAdminResponse.java
+++ b/src/main/java/com/sb/web/auth/dto/MemberAdminResponse.java
@@ -18,6 +18,7 @@ public class MemberAdminResponse {
private String name;
private String email;
private String role; // USER / ADMIN
+ private String plan; // FREE / PREMIUM
private String status; // ACTIVE / SUSPENDED / WITHDRAWN
private String provider; // LOCAL / 소셜
private LocalDateTime createdAt;
@@ -29,6 +30,7 @@ public class MemberAdminResponse {
.name(m.getName())
.email(m.getEmail())
.role(m.getRole())
+ .plan(m.getPlan())
.status(m.getStatus())
.provider(m.getProvider())
.createdAt(m.getCreatedAt())
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 d5af7db..a19e3d9 100644
--- a/src/main/java/com/sb/web/auth/dto/MemberResponse.java
+++ b/src/main/java/com/sb/web/auth/dto/MemberResponse.java
@@ -17,6 +17,7 @@ public class MemberResponse {
private String email;
private String provider;
private String role;
+ private String plan; // FREE / PREMIUM
public static MemberResponse from(Member m) {
return MemberResponse.builder()
@@ -26,6 +27,7 @@ public class MemberResponse {
.email(m.getEmail())
.provider(m.getProvider())
.role(m.getRole())
+ .plan(m.getPlan())
.build();
}
}
diff --git a/src/main/java/com/sb/web/auth/dto/SessionUser.java b/src/main/java/com/sb/web/auth/dto/SessionUser.java
index f96255d..db8dbb3 100644
--- a/src/main/java/com/sb/web/auth/dto/SessionUser.java
+++ b/src/main/java/com/sb/web/auth/dto/SessionUser.java
@@ -22,6 +22,7 @@ public class SessionUser implements Serializable {
private String loginId;
private String name;
private String role;
+ private String plan; // FREE / PREMIUM
private String provider;
private boolean rememberMe; // 자동 로그인 세션 여부 (슬라이딩 만료 TTL 결정용)
@@ -31,6 +32,7 @@ public class SessionUser implements Serializable {
.loginId(m.getLoginId())
.name(m.getName())
.role(m.getRole())
+ .plan(m.getPlan())
.provider(m.getProvider())
.build();
}
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 f1c72f2..e497ca8 100644
--- a/src/main/java/com/sb/web/auth/mapper/MemberMapper.java
+++ b/src/main/java/com/sb/web/auth/mapper/MemberMapper.java
@@ -42,6 +42,8 @@ public interface MemberMapper {
int updateRole(@Param("id") Long id, @Param("role") String role);
+ int updatePlan(@Param("id") Long id, @Param("plan") String plan);
+
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/web/PremiumInterceptor.java b/src/main/java/com/sb/web/auth/web/PremiumInterceptor.java
new file mode 100644
index 0000000..bc1d93f
--- /dev/null
+++ b/src/main/java/com/sb/web/auth/web/PremiumInterceptor.java
@@ -0,0 +1,39 @@
+package com.sb.web.auth.web;
+
+import com.sb.web.auth.dto.SessionUser;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Component;
+import org.springframework.web.servlet.HandlerInterceptor;
+
+import java.nio.charset.StandardCharsets;
+
+/**
+ * 유료(PREMIUM) 전용 경로 보호. AuthInterceptor 가 먼저 세팅한 SessionUser 의 plan 을 검사한다.
+ * 프론트의 메뉴 잠금은 우회 가능하므로, 실제 API 접근은 여기서 차단한다.
+ * 관리자(ADMIN)는 플랜과 무관하게 모든 기능을 사용할 수 있다.
+ */
+@Component
+public class PremiumInterceptor implements HandlerInterceptor {
+
+ @Override
+ public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
+ throws Exception {
+ if (HttpMethod.OPTIONS.matches(request.getMethod())) {
+ return true;
+ }
+ Object attr = request.getAttribute(AuthInterceptor.CURRENT_MEMBER);
+ if (attr instanceof SessionUser user
+ && ("ADMIN".equals(user.getRole()) || "PREMIUM".equals(user.getPlan()))) {
+ return true;
+ }
+ response.setStatus(HttpStatus.FORBIDDEN.value());
+ response.setContentType(MediaType.APPLICATION_JSON_VALUE);
+ response.setCharacterEncoding(StandardCharsets.UTF_8.name());
+ response.getWriter().write("{\"status\":403,\"code\":\"PREMIUM_REQUIRED\",\"message\":\"유료 멤버십 전용 기능입니다.\"}");
+ return false;
+ }
+}
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 f36fc5a..efe5084 100644
--- a/src/main/java/com/sb/web/common/config/WebConfig.java
+++ b/src/main/java/com/sb/web/common/config/WebConfig.java
@@ -2,6 +2,7 @@ package com.sb.web.common.config;
import com.sb.web.auth.web.AdminInterceptor;
import com.sb.web.auth.web.AuthInterceptor;
+import com.sb.web.auth.web.PremiumInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
@@ -18,6 +19,7 @@ public class WebConfig implements WebMvcConfigurer {
private final AuthInterceptor authInterceptor;
private final AdminInterceptor adminInterceptor;
+ private final PremiumInterceptor premiumInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
@@ -29,5 +31,18 @@ public class WebConfig implements WebMvcConfigurer {
// 인가: 관리자 전용 경로 (authInterceptor 다음에 실행되어 role 검사)
registry.addInterceptor(adminInterceptor)
.addPathPatterns("/api/admin/**");
+ // 인가: 유료(PREMIUM) 전용 경로 (authInterceptor 다음에 실행되어 plan 검사)
+ registry.addInterceptor(premiumInterceptor)
+ .addPathPatterns(
+ "/api/account/stats",
+ "/api/account/category-stats",
+ "/api/account/networth/trend",
+ "/api/account/budgets", "/api/account/budgets/**",
+ "/api/account/recurrings", "/api/account/recurrings/**",
+ "/api/account/ocr", "/api/account/ocr/**",
+ "/api/account/invest", "/api/account/invest/**",
+ "/api/account/tags", "/api/account/tags/**",
+ "/api/account/entries/notification",
+ "/api/account/restore");
}
}
diff --git a/src/main/resources/db/member.sql b/src/main/resources/db/member.sql
index 56a9184..c2d1646 100644
--- a/src/main/resources/db/member.sql
+++ b/src/main/resources/db/member.sql
@@ -27,6 +27,9 @@ ALTER TABLE member ADD UNIQUE KEY IF NOT EXISTS uk_member_google_id (google_id);
-- 기존 구글 계정(provider=GOOGLE)의 sub 를 google_id 로 백필(멱등 — 이미 채워졌으면 아무 것도 안 함).
UPDATE member SET google_id = provider_id WHERE provider = 'GOOGLE' AND google_id IS NULL AND provider_id IS NOT NULL;
+-- 멤버십 플랜 (무료/유료). 기존 회원은 FREE 로 시작 (멱등).
+ALTER TABLE member ADD COLUMN IF NOT EXISTS plan VARCHAR(20) NOT NULL DEFAULT 'FREE' COMMENT 'FREE / PREMIUM' AFTER role;
+
-- ============================================================
-- 앱 전역 설정 (단일 행, id=1). 관리자 화면에서 변경.
-- ============================================================
@@ -47,6 +50,7 @@ CREATE TABLE IF NOT EXISTS auth_session (
login_id VARCHAR(50) NULL,
name VARCHAR(100) NULL,
role VARCHAR(20) NULL,
+ plan VARCHAR(20) NULL,
provider VARCHAR(20) NULL,
remember_me TINYINT(1) NOT NULL DEFAULT 0,
expires_at DATETIME NOT NULL,
@@ -54,3 +58,5 @@ CREATE TABLE IF NOT EXISTS auth_session (
PRIMARY KEY (token),
KEY idx_auth_session_expires (expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+-- 멤버십 플랜 백업 (기존 세션 테이블에도 멱등 추가).
+ALTER TABLE auth_session ADD COLUMN IF NOT EXISTS plan VARCHAR(20) NULL AFTER role;
diff --git a/src/main/resources/mapper/AuthSessionMapper.xml b/src/main/resources/mapper/AuthSessionMapper.xml
index d0bdb69..94ff326 100644
--- a/src/main/resources/mapper/AuthSessionMapper.xml
+++ b/src/main/resources/mapper/AuthSessionMapper.xml
@@ -5,16 +5,16 @@
INSERT INTO auth_session
- (token, member_id, login_id, name, role, provider, remember_me, expires_at, created_at)
+ (token, member_id, login_id, name, role, plan, provider, remember_me, expires_at, created_at)
VALUES
- (#{token}, #{memberId}, #{loginId}, #{name}, #{role}, #{provider},
+ (#{token}, #{memberId}, #{loginId}, #{name}, #{role}, #{plan}, #{provider},
#{rememberMe}, #{expiresAt}, NOW())
ON DUPLICATE KEY UPDATE
expires_at = #{expiresAt}
diff --git a/src/main/resources/mapper/MemberMapper.xml b/src/main/resources/mapper/MemberMapper.xml
index fd7d92b..3506bf8 100644
--- a/src/main/resources/mapper/MemberMapper.xml
+++ b/src/main/resources/mapper/MemberMapper.xml
@@ -13,13 +13,14 @@
+
- id, login_id, password, name, email, provider, provider_id, google_id, role, status, created_at, updated_at
+ id, login_id, password, name, email, provider, provider_id, google_id, role, plan, status, created_at, updated_at