From 6256d3e63d832587c5cbccf2ab7f0a3203dafeb3 Mon Sep 17 00:00:00 2001 From: ByungCheol Date: Sun, 28 Jun 2026 11:02:19 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A9=A4=EB=B2=84=EC=8B=AD(=EB=AC=B4?= =?UTF-8?q?=EB=A3=8C/=EC=9C=A0=EB=A3=8C)=20=EA=B2=8C=EC=9D=B4=ED=8C=85=20?= =?UTF-8?q?=E2=80=94=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(FREE/PREMIUM) 컬럼 추가 + SessionUser/MemberResponse 노출 - auth_session 백업에 plan 저장(세션 복원 시 유료 상태 유지) - 관리자 회원 plan 변경 API (PUT /api/admin/members/{id}/plan) - PremiumInterceptor 로 유료 전용 경로 차단(통계/예산/고정지출/태그/OCR/투자/카드알림/백업) · 관리자는 플랜과 무관하게 허용, 무료 회원은 403 PREMIUM_REQUIRED - WebConfigTest: 유료 경로 등록 회귀 가드 추가 Co-Authored-By: Claude Opus 4.8 --- .../controller/MemberAdminController.java | 9 +++++ .../sb/web/admin/dto/PlanUpdateRequest.java | 14 +++++++ .../web/admin/service/MemberAdminService.java | 8 ++++ .../com/sb/web/auth/domain/AuthSession.java | 3 ++ .../java/com/sb/web/auth/domain/Member.java | 1 + .../sb/web/auth/dto/MemberAdminResponse.java | 2 + .../com/sb/web/auth/dto/MemberResponse.java | 2 + .../java/com/sb/web/auth/dto/SessionUser.java | 2 + .../com/sb/web/auth/mapper/MemberMapper.java | 2 + .../sb/web/auth/web/PremiumInterceptor.java | 39 +++++++++++++++++++ .../com/sb/web/common/config/WebConfig.java | 15 +++++++ src/main/resources/db/member.sql | 6 +++ .../resources/mapper/AuthSessionMapper.xml | 6 +-- src/main/resources/mapper/MemberMapper.xml | 7 +++- .../sb/web/common/config/WebConfigTest.java | 11 +++++- 15 files changed, 122 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/sb/web/admin/dto/PlanUpdateRequest.java create mode 100644 src/main/java/com/sb/web/auth/web/PremiumInterceptor.java 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