From 9861ba90ae20da018c5c9a77a2adaac86e0ea223 Mon Sep 17 00:00:00 2001 From: ByungCheol Date: Sun, 28 Jun 2026 11:02:44 +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=ED=94=84=EB=A1=A0=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth 스토어 isPremium/isAdmin 추가 (plan===PREMIUM 또는 관리자) - 사이드바: 통계·고정지출·예산·태그 메뉴에 무료 회원 잠금 배지(🔒) - 라우트 가드: 유료 전용 화면(requiresPremium) → 무료 회원은 /upgrade - UpgradeView: 무료/유료 기능 비교 + 업그레이드 안내 - http 인터셉터: 403 PREMIUM_REQUIRED → premium:required 이벤트 → 업그레이드 화면 - 무료 화면의 유료 API 호출 차단(홈 예산·내역 태그·OCR·카드알림 게이팅) - 회원관리: 멤버십 컬럼 + 무료/유료 토글(관리자) - 설정: 데이터 백업/복구 유료 게이팅, 계정정보에 멤버십 표시 Co-Authored-By: Claude Opus 4.8 --- src/App.vue | 10 +- src/api/adminApi.js | 3 + src/api/http.js | 3 + src/components/layout/AppSidebar.vue | 9 +- src/router/index.js | 19 ++- src/stores/auth.js | 5 +- src/views/HomeView.vue | 12 +- src/views/UpgradeView.vue | 171 +++++++++++++++++++++++++ src/views/UsersView.vue | 32 ++++- src/views/account/AccountView.vue | 13 +- src/views/settings/AccountInfoView.vue | 20 ++- src/views/settings/SettingsView.vue | 44 ++++--- 12 files changed, 309 insertions(+), 32 deletions(-) create mode 100644 src/views/UpgradeView.vue diff --git a/src/App.vue b/src/App.vue index 1d910db..1fa8be6 100644 --- a/src/App.vue +++ b/src/App.vue @@ -36,12 +36,20 @@ function onUnauthorized() { auth.clear() ui.openLogin() } +// http.js 의 403 PREMIUM_REQUIRED 처리 → 업그레이드 안내 화면으로 +function onPremiumRequired() { + if (route.name !== 'upgrade') router.push({ name: 'upgrade' }) +} onMounted(() => { if (!isApp) return // 웹은 안내만 — 앱 전용 초기화 생략 window.addEventListener('auth:unauthorized', onUnauthorized) + window.addEventListener('premium:required', onPremiumRequired) ui.loadSignupEnabled() // 회원가입 허용 여부 선로딩(랜딩/로그인 가입 버튼에 반영) }) -onUnmounted(() => window.removeEventListener('auth:unauthorized', onUnauthorized)) +onUnmounted(() => { + window.removeEventListener('auth:unauthorized', onUnauthorized) + window.removeEventListener('premium:required', onPremiumRequired) +}) // 페이지 이동 시 모바일 사이드바 자동 닫힘 watch(() => route.fullPath, () => ui.closeSidebar()) diff --git a/src/api/adminApi.js b/src/api/adminApi.js index 210277b..48a9a94 100644 --- a/src/api/adminApi.js +++ b/src/api/adminApi.js @@ -62,6 +62,9 @@ export const adminApi = { updateMemberRole(id, role) { return http.put(`/admin/members/${id}/role`, { role }) }, + updateMemberPlan(id, plan) { + return http.put(`/admin/members/${id}/plan`, { plan }) + }, updateMemberStatus(id, status) { return http.put(`/admin/members/${id}/status`, { status }) }, diff --git a/src/api/http.js b/src/api/http.js index 4bffdc6..ca6b346 100644 --- a/src/api/http.js +++ b/src/api/http.js @@ -31,6 +31,9 @@ http.interceptors.response.use( localStorage.removeItem('token') localStorage.removeItem('auth_user') window.dispatchEvent(new CustomEvent('auth:unauthorized')) + } else if (status === 403 && error.response?.data?.code === 'PREMIUM_REQUIRED') { + // 유료 전용 API 를 무료 회원이 호출(우회/직접 접근) — 업그레이드 안내 + window.dispatchEvent(new CustomEvent('premium:required')) } return Promise.reject(error) }, diff --git a/src/components/layout/AppSidebar.vue b/src/components/layout/AppSidebar.vue index 1f8fc72..430b86e 100644 --- a/src/components/layout/AppSidebar.vue +++ b/src/components/layout/AppSidebar.vue @@ -10,6 +10,8 @@ const auth = useAuthStore() const ui = useUiStore() // 로그인 또는 둘러보기(데모)면 메뉴 노출. 데모에선 잠금 배지를 함께 표시. const showMenu = computed(() => auth.isAuthenticated || demo.on) +// 유료 전용 메뉴 잠금 배지: 데모이거나, 로그인했지만 무료 회원일 때. +const lockPremium = computed(() => demo.on || (auth.isAuthenticated && !auth.isPremium)) // 메뉴 아이콘 (lucide 스타일 인라인 SVG path — 하단 내비와 동일 톤). 값은 정적/신뢰 마크업. const icons = { @@ -45,11 +47,12 @@ const icons = { 통계 - 🔒 + 🔒 고정 지출 + 🔒 @@ -62,12 +65,12 @@ const icons = { 예산 설정 - 🔒 + 🔒 태그 관리 - 🔒 + 🔒 diff --git a/src/router/index.js b/src/router/index.js index 36f10f0..c26da86 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -24,6 +24,13 @@ const router = createRouter({ name: 'demo-locked', component: () => import('../views/DemoLockedView.vue'), }, + { + // 유료 멤버십 안내/업그레이드 (로그인 필요, 무료 회원도 접근 가능) + path: '/upgrade', + name: 'upgrade', + component: () => import('../views/UpgradeView.vue'), + meta: { requiresAuth: true }, + }, { path: '/users', name: 'users', @@ -85,13 +92,13 @@ const router = createRouter({ path: '/account/stats', name: 'account-stats', component: () => import('../views/account/AccountDashboardView.vue'), - meta: { requiresAuth: true }, + meta: { requiresAuth: true, requiresPremium: true }, }, { path: '/account/tags', name: 'account-tags', component: () => import('../views/account/AccountTagView.vue'), - meta: { requiresAuth: true }, + meta: { requiresAuth: true, requiresPremium: true }, }, { path: '/account/wallets', @@ -109,13 +116,13 @@ const router = createRouter({ path: '/account/budget', name: 'account-budget', component: () => import('../views/account/BudgetView.vue'), - meta: { requiresAuth: true }, + meta: { requiresAuth: true, requiresPremium: true }, }, { path: '/account/recurrings', name: 'account-recurrings', component: () => import('../views/account/RecurringView.vue'), - meta: { requiresAuth: true }, + meta: { requiresAuth: true, requiresPremium: true }, }, // 설정 (앱 하단 내비게이션 → 설정) { @@ -161,6 +168,10 @@ router.beforeEach((to, from) => { if (to.meta.requiresAdmin && auth.user?.role !== 'ADMIN') { return { name: 'home' } } + // 유료 전용 페이지 — 무료 회원은 업그레이드 안내로 + if (to.meta.requiresPremium && !auth.isPremium) { + return { name: 'upgrade' } + } }) // 새 배포로 청크 해시가 바뀐 뒤, 캐시된 옛 index.html 이 삭제된 옛 청크를 부르면 diff --git a/src/stores/auth.js b/src/stores/auth.js index 6656325..3d8991f 100644 --- a/src/stores/auth.js +++ b/src/stores/auth.js @@ -15,6 +15,9 @@ export const useAuthStore = defineStore('auth', () => { const ready = ref(false) const isAuthenticated = computed(() => !!token.value) + const isAdmin = computed(() => user.value?.role === 'ADMIN') + // 유료(PREMIUM) 멤버십 여부. 관리자는 항상 유료 기능 사용 가능. + const isPremium = computed(() => isAdmin.value || user.value?.plan === 'PREMIUM') // localStorage 동기화 (http.js 가 동기로 토큰을 읽음) function mirrorLocal() { @@ -109,7 +112,7 @@ export const useAuthStore = defineStore('auth', () => { await persist() } - return { token, user, ready, isAuthenticated, restore, login, googleLogin, signup, fetchMe, applyUser, logout, clear } + return { token, user, ready, isAuthenticated, isAdmin, isPremium, restore, login, googleLogin, signup, fetchMe, applyUser, logout, clear } }) function safeParse(value) { diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue index 9da5c55..1b7b5e8 100644 --- a/src/views/HomeView.vue +++ b/src/views/HomeView.vue @@ -113,10 +113,11 @@ async function load() { loading.value = true error.value = null try { + // 예산은 유료 전용 — 무료 회원은 호출하지 않음(403/업그레이드 리다이렉트 방지) const [sum, nw, status, list] = await Promise.all([ accountApi.summary({ year, month }), accountApi.netWorth(), - accountApi.budgetStatus({ year, month }), + auth.isPremium ? accountApi.budgetStatus({ year, month }) : Promise.resolve([]), accountApi.list({ year, month }), ]) summary.value = sum @@ -204,12 +205,14 @@ onMounted(load) - +
{{ month }}월 예산 대비 지출 - 예산 → + 예산 →
+ 👑 예산 관리는 유료 멤버십 기능이에요 → +
diff --git a/src/views/UpgradeView.vue b/src/views/UpgradeView.vue new file mode 100644 index 0000000..2220a23 --- /dev/null +++ b/src/views/UpgradeView.vue @@ -0,0 +1,171 @@ + + + + + diff --git a/src/views/UsersView.vue b/src/views/UsersView.vue index 3c4c7b0..11b74ca 100644 --- a/src/views/UsersView.vue +++ b/src/views/UsersView.vue @@ -79,6 +79,17 @@ async function changeRole(m, role) { } } +async function changePlan(m, plan) { + if (plan === m.plan) return + try { + const updated = await adminApi.updateMemberPlan(m.id, plan) + Object.assign(m, updated) + } catch (e) { + alert(e.response?.data?.message || '멤버십 변경 실패') + await load() + } +} + async function changeStatus(m, status) { if (status === m.status) return try { @@ -114,7 +125,7 @@ onMounted(() => { -

가입한 회원의 역할·상태를 관리합니다. (관리자 전용)

+

가입한 회원의 역할·멤버십·상태를 관리합니다. (관리자 전용)