feat(shop): 포인트 상점 프론트엔드 구현
Deploy / deploy (push) Failing after 11m25s

- accountApi: shopStatus(), buyShopItem(item) 추가; buyShopItem은 DEMO_WRITES에 등록
- router: /settings/shop → ShopView.vue 라우트 추가
- ShopView.vue: 포인트 잔액·상품 카드(계좌 슬롯/AI 통계) 구매 화면, PREMIUM 회원 안내 분기
- AccountInfoView: '기프트콘 교환' → '포인트 상점' 로 이름 변경 + 클릭 시 /settings/shop 이동
- AccountDashboardView: AI 코멘트 402 응답 시 크레딧 소진 안내 + 상점 이동 버튼 노출
- LAUNCH-PROGRESS: 포인트 상점  완료 표시, 다음 재개 지점 현행화

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-07-10 00:32:49 +09:00
parent 00d2945e39
commit 1a85e448aa
6 changed files with 419 additions and 25 deletions
+12 -17
View File
@@ -139,14 +139,12 @@
## 📌 남은 백로그 (출시 후 업데이트) ## 📌 남은 백로그 (출시 후 업데이트)
1. **앱 업데이트 안내** — 새 버전 알림 + 구글플레이 바로가기 1. **앱 업데이트 안내** — 새 버전 알림 + 구글플레이 바로가기
2. (선택) 첫 실행 온보딩 / Play 인앱 리뷰 / 생체 인증 2. (선택) 첫 실행 온보딩 / Play 인앱 리뷰 / 생체 인증
3. **조직 계정 전환 + 기프트콘 교환** — 사업자등록→DUNS→앱이전, B2B 기프트콘 매입 3. **조직 계정 전환** — 사업자등록→DUNS→앱이전
4. **포인트 상점 (사용량·슬롯 판매)** — 견적 ~1~1.5일. 무료회원 대상, 유료회원은 무제한. 4. **포인트 상점** (2026-07-10 구현 완료)
- **AI 텍스트 입력**(`/entries/parse`): 월 5회 무료 + **추가 10회 = 300P** - **계좌 슬롯**: 500P/개, 최대 3개, 영구. `extra_wallet_slots` (member 테이블)
- **OCR 영수증 인식**(`/ocr/receipt-ai`): 월 3회 무료 + **추가 10회 = 500P** - **AI 통계 분석**: 1000P → 10회 이용권. `ai_stat_credits` (member 테이블)
- **계좌 슬롯**: 종류별 기본 2개(적용됨) + **500P당 +1, 최대 5개**, 구매분은 **영구** - FREE 회원이 크레딧 없이 AI 코멘트 호출 시 402 → 통계 화면에서 상점 안내
- 설계: 월별 사용량 테이블(`ai_usage`) + 구매 크레딧(`ai_credit`) / 매 호출 전 `무료쿼터→구매크레딧→거부(충전유도)` / 구매는 PointService로 포인트 차감(+크레딧) - PREMIUM 회원은 AI 코멘트 무제한 (크레딧 불필요)
- 확정 필요: ①유료=무제한 ②구매팩 이월(기본은 매월 리셋) ③'1회'=AI API 호출 1건(비용 기준) ④월 1일 리셋
- 참고: AI 호출 비용 상한 통제 효과. 지금은 `/entries/parse`가 무료·미게이트(영수증·재무코멘트는 이미 유료 게이트) → 상점 없이 단순 유료화하려면 WebConfig 1줄로도 가능
### 🤖 AI 기능 — ✅ 구현·배포 완료 (2026-07-05) ### 🤖 AI 기능 — ✅ 구현·배포 완료 (2026-07-05)
- 백엔드: `AiEntryParser`(Claude Haiku 4.5, raw HTTP `/v1/messages`), 키는 서버 `/opt/sb-backend/.env`(`ANTHROPIC_API_KEY`), 실패/미설정 시 규칙기반·Google Vision OCR 폴백. - 백엔드: `AiEntryParser`(Claude Haiku 4.5, raw HTTP `/v1/messages`), 키는 서버 `/opt/sb-backend/.env`(`ANTHROPIC_API_KEY`), 실패/미설정 시 규칙기반·Google Vision OCR 폴백.
@@ -169,12 +167,9 @@
## 다음 세션 재개 지점 ## 다음 세션 재개 지점
0.**[해결] 화면 가로 밀림/잘림** — 진짜 원인은 **iOS input 자동확대**였음(모달·CSS 아님). 0.**[해결] 화면 가로 밀림/잘림** — 진짜 원인은 **iOS input 자동확대**였음(모달·CSS 아님).
viewport에 maximum-scale 없어 16px 미만 input 포커스 시 페이지 확대→우측 잘림. `maximum-scale=1.0, user-scalable=no` 추가로 해결(ec130e8). *교훈: "모든 화면 input 포커스 시 동일" = iOS 줌 의심.* 1. **테스터 12명 opt-in 진행상황 확인**(업체 의뢰) → 12명 + 14일 연속 충족되면 **프로덕션 액세스 신청**
1. **테스터 12명 opt-in 진행상황 확인**(업체 의뢰) → 12명 + 14일 연속 충족되면 **프로덕션 액세스 신청**(`production-access-draft.md`) 2. **v5 AAB 비공개 트랙 업로드** — versionCode 5(1.0.4) 빌드 완료, Play Console 업로드 대기
2. **v4 AAB 비공개 트랙 업로드** — versionCode 4(1.0.3) 빌드 완료, Play Console 업로드 대기 - 포인트 상점 포함 이번 작업분 서버 배포 확인 후 AAB 업로드
- ⚠️ 2026-07-07 추가분(정기결제·계좌개편·상환조회·월상환금 등)은 **웹·서버 배포 완료** — 다음 AAB에 포함해 빌드 3. **DUNS 발급 대기** → 발급 시 Play Console 개인사업자 계정 전환
3. **포인트 상점** — 견적 ~1~1.5일. 착수 전 정책 4가지 확정 필요: 4. (선택) **음성 전용 🎤 버튼**`@capacitor-community/speech-recognition`
- ①유료 회원 = AI 무제한 여부 ②구매 크레딧 이월 vs 매월 리셋 ③1회=API 호출 1건 ④리셋 기준일=월 1일 5. **iOS(App Store) 트랙** — 실기기 빌드 확인 완료. 애플 개발자 승인·번들ID·App Store 심사는 Play와 독립 진행
4. **DUNS 발급 대기** → 발급 시 Play Console 개인사업자 계정 전환 마저 진행
5. (선택) **음성 전용 🎤 버튼**`@capacitor-community/speech-recognition`(현재는 iOS 키보드 받아쓰기로 대체 가능)
6. **iOS(App Store) 트랙** — 실기기 빌드 확인 완료. 애플 개발자 승인·번들ID·App Store 심사는 Play와 독립 진행
+9 -1
View File
@@ -225,6 +225,14 @@ const realApi = {
return http.delete('/account/all') return http.delete('/account/all')
}, },
// 포인트 상점 — 현황 조회 / 아이템 구매
shopStatus() {
return http.get('/account/shop/status')
},
buyShopItem(item) {
return http.post('/account/shop/buy', { item })
},
// 영수증 OCR (백엔드가 Google Vision 호출 → 전체 텍스트 반환) — AI 폴백용 // 영수증 OCR (백엔드가 Google Vision 호출 → 전체 텍스트 반환) — AI 폴백용
// suppressNetError: runOcr 이 자체 formError 로 안내하므로 전역 배너 억제 // suppressNetError: runOcr 이 자체 formError 로 안내하므로 전역 배너 억제
ocrReceipt(blob) { ocrReceipt(blob) {
@@ -271,7 +279,7 @@ const DEMO_WRITES = new Set([
'createRecurring', 'updateRecurring', 'removeRecurring', 'runRecurrings', 'createRecurring', 'updateRecurring', 'removeRecurring', 'runRecurrings',
'createWallet', 'updateWallet', 'removeWallet', 'reorderWallets', 'createWallet', 'updateWallet', 'removeWallet', 'reorderWallets',
'createCategory', 'updateCategory', 'removeCategory', 'reorderCategories', 'importCategories', 'createCategory', 'updateCategory', 'removeCategory', 'reorderCategories', 'importCategories',
'createTag', 'updateTag', 'removeTag', 'reorderTags', 'confirmEntry', 'ocrReceipt', 'parseReceiptAi', 'aiComment', 'restore', 'deleteAllData', 'createTag', 'updateTag', 'removeTag', 'reorderTags', 'confirmEntry', 'ocrReceipt', 'parseReceiptAi', 'aiComment', 'restore', 'deleteAllData', 'buyShopItem',
'createBudget', 'updateBudget', 'removeBudget', 'setExpectedIncome', 'createBudget', 'updateBudget', 'removeBudget', 'setExpectedIncome',
'createHolding', 'updateHolding', 'removeHolding', 'addTrade', 'updateTrade', 'removeTrade', 'createHolding', 'updateHolding', 'removeHolding', 'addTrade', 'updateTrade', 'removeTrade',
'refreshPrices', 'refreshAllPrices', 'refreshPrices', 'refreshAllPrices',
+6
View File
@@ -213,6 +213,12 @@ const router = createRouter({
component: () => import('../views/settings/ProfileEditView.vue'), component: () => import('../views/settings/ProfileEditView.vue'),
meta: { requiresAuth: true }, meta: { requiresAuth: true },
}, },
{
path: '/settings/shop',
name: 'settings-shop',
component: () => import('../views/settings/ShopView.vue'),
meta: { requiresAuth: true },
},
], ],
}) })
+36 -1
View File
@@ -1,8 +1,11 @@
<script setup> <script setup>
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { accountApi } from '@/api/accountApi' import { accountApi } from '@/api/accountApi'
import IconBtn from '@/components/ui/IconBtn.vue' import IconBtn from '@/components/ui/IconBtn.vue'
const router = useRouter()
const now = new Date() const now = new Date()
const year = ref(now.getFullYear()) const year = ref(now.getFullYear())
const month = ref(now.getMonth() + 1) const month = ref(now.getMonth() + 1)
@@ -32,6 +35,7 @@ const forecastHint = ref('') // 초반(표본 부족)엔 값 대신 안내
const FORECAST_MIN_DAYS = 10 // 이 날짜(경과일)부터 예상 지출 노출(초반 표본 부족·튐 방지) const FORECAST_MIN_DAYS = 10 // 이 날짜(경과일)부터 예상 지출 노출(초반 표본 부족·튐 방지)
const forecastLoading = ref(false) const forecastLoading = ref(false)
const aiLoading = ref(false) const aiLoading = ref(false)
const aiNoCredit = ref(false) // FREE 회원 크레딧 소진 상태
const periodLabel = computed(() => `${year.value}${String(month.value).padStart(2, '0')}`) const periodLabel = computed(() => `${year.value}${String(month.value).padStart(2, '0')}`)
@@ -334,6 +338,7 @@ async function load() {
// 이번 달 집계만 백엔드로 보내 AI 재무 코멘트를 받는다(거래 원문 미전송, 실패해도 무중단) // 이번 달 집계만 백엔드로 보내 AI 재무 코멘트를 받는다(거래 원문 미전송, 실패해도 무중단)
async function loadAiComment() { async function loadAiComment() {
aiLoading.value = true aiLoading.value = true
aiNoCredit.value = false
aiBullets.value = [] aiBullets.value = []
aiTips.value = [] aiTips.value = []
try { try {
@@ -349,7 +354,10 @@ async function loadAiComment() {
}) })
aiBullets.value = res?.bullets || [] aiBullets.value = res?.bullets || []
aiTips.value = res?.tips || [] aiTips.value = res?.tips || []
} catch { } catch (e) {
if (e.response?.status === 402) {
aiNoCredit.value = true
}
aiBullets.value = [] aiBullets.value = []
aiTips.value = [] aiTips.value = []
} finally { } finally {
@@ -421,6 +429,13 @@ onMounted(async () => {
<p v-if="loading" class="msg">불러오는 중...</p> <p v-if="loading" class="msg">불러오는 중...</p>
<template v-else> <template v-else>
<!-- 무료 회원 AI 크레딧 소진 안내 -->
<div v-if="aiNoCredit" class="ai-comment ai-no-credit">
<div class="ai-comment-head"> AI 코멘트</div>
<p class="ai-credit-msg">AI 통계 분석 이용권이 소진되었습니다.<br>포인트 상점에서 추가 구매할 있어요.</p>
<button type="button" class="ai-shop-btn" @click="router.push('/settings/shop')">포인트 상점 </button>
</div>
<!-- AI 재무 코멘트 + 절약 가이드: 이번 집계 해석 --> <!-- AI 재무 코멘트 + 절약 가이드: 이번 집계 해석 -->
<div v-if="aiLoading || aiBullets.length || aiTips.length" class="ai-comment"> <div v-if="aiLoading || aiBullets.length || aiTips.length" class="ai-comment">
<div class="ai-comment-head"> 이번 AI 코멘트</div> <div class="ai-comment-head"> 이번 AI 코멘트</div>
@@ -725,6 +740,26 @@ h2 {
color: #b8860b; color: #b8860b;
margin-bottom: 0.4rem; margin-bottom: 0.4rem;
} }
.ai-no-credit {
border-color: hsla(220, 80%, 60%, 0.4);
}
.ai-credit-msg {
font-size: 0.88rem;
color: var(--color-muted, #888);
line-height: 1.5;
margin: 0.4rem 0 0.7rem;
}
.ai-shop-btn {
display: inline-block;
padding: 6px 14px;
border-radius: 7px;
border: none;
background: #2563eb;
color: #fff;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
}
.panel-head { .panel-head {
display: flex; display: flex;
align-items: center; align-items: center;
+5 -6
View File
@@ -161,9 +161,8 @@ async function restoreSub() {
} }
} }
// 포인트 기프트콘 교환 — 업데이트 예정 function goShop() {
function exchangeGiftcon() { router.push('/settings/shop')
dialog.alert('포인트 기프트콘 교환은 업데이트 예정입니다. 조금만 기다려 주세요!')
} }
// 플랜 구독/변경 — Android 만 결제, PC 는 업그레이드(앱 안내) 화면으로 // 플랜 구독/변경 — Android 만 결제, PC 는 업그레이드(앱 안내) 화면으로
@@ -323,9 +322,9 @@ async function withdraw() {
<span class="point-more">내역 </span> <span class="point-more">내역 </span>
</span> </span>
</button> </button>
<button type="button" class="row row-point" @click="exchangeGiftcon"> <button type="button" class="row row-point" @click="goShop">
<span class="k">기프트콘 교환</span> <span class="k">포인트 상점</span>
<span class="v"><span class="soon">업데이트 예정</span></span> <span class="v"><span class="point-more"></span></span>
</button> </button>
</section> </section>
+351
View File
@@ -0,0 +1,351 @@
<template>
<div class="shop-wrap">
<header class="shop-header">
<button type="button" class="back-btn" @click="router.back()"></button>
<h1 class="shop-title">포인트 상점</h1>
</header>
<div v-if="loading" class="shop-loading">불러오는 </div>
<template v-else>
<!-- 포인트 잔액 -->
<section class="points-bar">
<span class="points-label">보유 포인트</span>
<span class="points-value">{{ points.toLocaleString('ko-KR') }} P</span>
</section>
<!-- 유료 회원 안내 -->
<div v-if="isPremium" class="premium-notice">
유료 멤버십 이용 모든 기능 무제한 사용 가능합니다.<br>
포인트 상점 아이템은 무료 회원 전용입니다.
</div>
<!-- 상품 목록 -->
<section v-else class="product-list">
<!-- 추가 계좌 슬롯 -->
<div class="product-card">
<div class="product-icon">🏦</div>
<div class="product-info">
<div class="product-name">추가 계좌 슬롯</div>
<div class="product-desc">
무료 회원은 종류별 계좌 2개까지 등록 가능합니다.<br>
슬롯 1 구매 모든 종류에 +1 추가 (최대 3 구매 가능).
</div>
<div class="product-status">
보유 슬롯: <strong>{{ status.extraWalletSlots }}</strong> / {{ status.maxExtraSlots }}
</div>
</div>
<div class="product-buy">
<div class="product-price">{{ status.walletSlotPrice.toLocaleString('ko-KR') }} P</div>
<button
type="button"
class="buy-btn"
:disabled="status.extraWalletSlots >= status.maxExtraSlots || buying === 'WALLET_SLOT'"
@click="buy('WALLET_SLOT')"
>
<template v-if="status.extraWalletSlots >= status.maxExtraSlots">최대 구매</template>
<template v-else-if="buying === 'WALLET_SLOT'">구매 </template>
<template v-else>구매</template>
</button>
</div>
</div>
<!-- AI 통계 분석 -->
<div class="product-card">
<div class="product-icon"></div>
<div class="product-info">
<div class="product-name">AI 통계 분석 {{ status.aiStatPackSize }}</div>
<div class="product-desc">
통계 화면에서 AI 재무 코멘트를 받을 있습니다.<br>
{{ status.aiStatPackSize }} 이용권 구매 (소진 재구매 가능).
</div>
<div class="product-status">
남은 이용권: <strong>{{ status.aiStatCredits }}</strong>
</div>
</div>
<div class="product-buy">
<div class="product-price">{{ status.aiStatPrice.toLocaleString('ko-KR') }} P</div>
<button
type="button"
class="buy-btn"
:disabled="buying === 'AI_STAT'"
@click="buy('AI_STAT')"
>
<template v-if="buying === 'AI_STAT'">구매 </template>
<template v-else>구매</template>
</button>
</div>
</div>
</section>
<!-- 포인트 적립 안내 -->
<section class="earn-guide">
<h3 class="earn-title">포인트 적립 방법</h3>
<ul class="earn-list">
<li>가계부 내역 작성: <strong>10P</strong> ( 5 한도)</li>
<li>게시판 작성: <strong>10P</strong> ( 5 한도)</li>
<li>게시글 추천받기: <strong>5P</strong></li>
<li>남의 추천하기: <strong>1P</strong></li>
<li> 개근 보너스: <strong>최대 100P</strong></li>
</ul>
</section>
</template>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useDialog } from '@/composables/dialog'
import { accountApi } from '@/api/accountApi'
import { authApi } from '@/api/authApi'
const router = useRouter()
const auth = useAuthStore()
const dialog = useDialog()
const loading = ref(true)
const buying = ref('')
const points = ref(0)
const status = ref({
extraWalletSlots: 0,
aiStatCredits: 0,
maxExtraSlots: 3,
walletSlotPrice: 500,
aiStatPrice: 1000,
aiStatPackSize: 10,
})
const isPremium = computed(() => auth.user?.plan === 'PREMIUM')
async function load() {
loading.value = true
try {
const [pt, st] = await Promise.all([authApi.points(), accountApi.shopStatus()])
points.value = pt.points ?? 0
status.value = st
} finally {
loading.value = false
}
}
async function buy(item) {
const labels = { WALLET_SLOT: '추가 계좌 슬롯', AI_STAT: `AI 통계 분석 ${status.value.aiStatPackSize}` }
const prices = { WALLET_SLOT: status.value.walletSlotPrice, AI_STAT: status.value.aiStatPrice }
const ok = await dialog.confirm(
`${labels[item]} (${prices[item].toLocaleString('ko-KR')}P)를 구매하시겠습니까?`
)
if (!ok) return
buying.value = item
try {
status.value = await accountApi.buyShopItem(item)
const pt = await authApi.points()
points.value = pt.points ?? 0
dialog.alert('구매가 완료되었습니다!')
} catch (e) {
const msg = e.response?.data?.message
if (msg === 'SHOP_NOT_ENOUGH_POINTS') {
dialog.alert('포인트가 부족합니다.')
} else if (msg === 'SHOP_WALLET_SLOT_MAX') {
dialog.alert('추가 계좌 슬롯은 최대 3개까지 구매할 수 있습니다.')
} else {
dialog.alert('구매 중 오류가 발생했습니다.')
}
} finally {
buying.value = ''
}
}
onMounted(load)
</script>
<style scoped>
.shop-wrap {
max-width: 480px;
margin: 0 auto;
padding: 0 0 40px;
min-height: 100vh;
background: var(--color-bg, #f7f8fa);
}
.shop-header {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 16px 12px;
background: var(--color-surface, #fff);
border-bottom: 1px solid var(--color-border, #e8eaed);
}
.back-btn {
font-size: 22px;
line-height: 1;
background: none;
border: none;
cursor: pointer;
color: var(--color-text, #222);
padding: 0 4px;
}
.shop-title {
font-size: 17px;
font-weight: 700;
margin: 0;
}
.shop-loading {
padding: 48px;
text-align: center;
color: var(--color-muted, #888);
}
.points-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
background: var(--color-surface, #fff);
margin: 12px 16px;
border-radius: 12px;
box-shadow: 0 1px 4px rgba(0,0,0,.07);
}
.points-label {
font-size: 14px;
color: var(--color-muted, #888);
}
.points-value {
font-size: 18px;
font-weight: 700;
color: #2563eb;
}
.premium-notice {
margin: 12px 16px;
padding: 16px;
border-radius: 12px;
background: #f0fdf4;
color: #166534;
font-size: 14px;
line-height: 1.6;
border: 1px solid #bbf7d0;
}
.product-list {
display: flex;
flex-direction: column;
gap: 12px;
padding: 0 16px;
margin-top: 4px;
}
.product-card {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px;
background: var(--color-surface, #fff);
border-radius: 14px;
box-shadow: 0 1px 4px rgba(0,0,0,.07);
}
.product-icon {
font-size: 28px;
flex-shrink: 0;
margin-top: 2px;
}
.product-info {
flex: 1;
min-width: 0;
}
.product-name {
font-size: 15px;
font-weight: 700;
margin-bottom: 4px;
}
.product-desc {
font-size: 13px;
color: var(--color-muted, #888);
line-height: 1.5;
margin-bottom: 6px;
}
.product-status {
font-size: 13px;
color: var(--color-text, #222);
}
.product-buy {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
flex-shrink: 0;
}
.product-price {
font-size: 15px;
font-weight: 700;
color: #2563eb;
white-space: nowrap;
}
.buy-btn {
padding: 7px 16px;
border-radius: 8px;
border: none;
background: #2563eb;
color: #fff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition: opacity .15s;
}
.buy-btn:disabled {
background: #c0c8d8;
cursor: default;
}
.earn-guide {
margin: 20px 16px 0;
padding: 16px;
background: var(--color-surface, #fff);
border-radius: 14px;
box-shadow: 0 1px 4px rgba(0,0,0,.07);
}
.earn-title {
font-size: 14px;
font-weight: 700;
margin: 0 0 10px;
}
.earn-list {
margin: 0;
padding: 0 0 0 18px;
list-style: disc;
font-size: 13px;
color: var(--color-muted, #888);
line-height: 1.8;
}
.earn-list strong {
color: var(--color-text, #222);
}
@media (prefers-color-scheme: dark) {
.shop-wrap { background: #111; }
.shop-header { background: #1c1c1e; border-color: #2c2c2e; }
.points-bar, .product-card, .earn-guide { background: #1c1c1e; box-shadow: none; }
.premium-notice { background: #052e16; color: #bbf7d0; border-color: #166534; }
}
</style>