feat(shop): 포인트 상점 구현 — 계좌 슬롯(500P)·AI 통계 크레딧(1000P→10회)
Deploy / deploy (push) Successful in 56s

- member 테이블: extra_wallet_slots, ai_stat_credits 컬럼 추가(ALTER IF NOT EXISTS)
- MemberMapper: 상점 관련 5개 메서드 추가(getExtraWalletSlots/getAiStatCredits/incrementExtraWalletSlots/addAiStatCredits/decrementAiStatCredit)
- ShopService: buyWalletSlot(500P, max 3개), buyAiStatPack(1000P→10회) 구현
- ShopController: GET /api/account/shop/status, POST /api/account/shop/buy
- AccountService.freeWalletLimit: bonus=0 TODO → memberMapper.getExtraWalletSlots 실제 조회
- AccountService.aiComment: FREE 회원 크레딧 차감 로직 추가, 소진 시 402 반환
- AccountController.aiComment: current 파라미터 전달
- WebConfig: /api/account/ai-comment 를 premiumInterceptor 에서 제거(서비스 레이어에서 처리)
- BackupController: @DeleteMapping import 누락 수정

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-07-10 00:32:30 +09:00
parent 75d5c9fa9b
commit 6db6cb055e
10 changed files with 177 additions and 8 deletions
@@ -252,12 +252,12 @@ public class AccountController {
return accountService.parseText(req); return accountService.parseText(req);
} }
/** 통계 화면 집계 → AI 재무 코멘트(유료). 집계만 전송, 거래 원문 미전송. */ /** 통계 화면 집계 → AI 재무 코멘트. PREMIUM 무제한, FREE 는 크레딧 차감. */
@PostMapping("/ai-comment") @PostMapping("/ai-comment")
public com.sb.web.account.dto.AiCommentResponse aiComment( public com.sb.web.account.dto.AiCommentResponse aiComment(
@RequestBody com.sb.web.account.dto.AiCommentRequest req, @RequestBody com.sb.web.account.dto.AiCommentRequest req,
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
return accountService.aiComment(req); return accountService.aiComment(req, current);
} }
/** 미확인 내역 확정 (분류/계좌 보정) */ /** 미확인 내역 확정 (분류/계좌 보정) */
@@ -6,10 +6,11 @@ import com.sb.web.auth.dto.SessionUser;
import com.sb.web.auth.web.AuthInterceptor; import com.sb.web.auth.web.AuthInterceptor;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestAttribute;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
/** /**
@@ -0,0 +1,37 @@
package com.sb.web.account.controller;
import com.sb.web.account.dto.ShopStatusResponse;
import com.sb.web.account.service.ShopService;
import com.sb.web.auth.dto.SessionUser;
import com.sb.web.auth.web.AuthInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/account/shop")
@RequiredArgsConstructor
public class ShopController {
private final ShopService shopService;
@GetMapping("/status")
public ShopStatusResponse status(
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
return shopService.getStatus(current.getId());
}
@PostMapping("/buy")
public ShopStatusResponse buy(
@RequestBody Map<String, String> body,
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
String item = body.get("item");
return switch (item) {
case "WALLET_SLOT" -> shopService.buyWalletSlot(current.getId());
case "AI_STAT" -> shopService.buyAiStatPack(current.getId());
default -> throw new org.springframework.web.server.ResponseStatusException(
org.springframework.http.HttpStatus.BAD_REQUEST, "SHOP_UNKNOWN_ITEM");
};
}
}
@@ -0,0 +1,15 @@
package com.sb.web.account.dto;
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class ShopStatusResponse {
private int extraWalletSlots;
private int aiStatCredits;
private int maxExtraSlots;
private int walletSlotPrice;
private int aiStatPrice;
private int aiStatPackSize;
}
@@ -21,6 +21,8 @@ import com.sb.web.account.dto.WalletResponse;
import com.sb.web.account.mapper.AccountEntryMapper; import com.sb.web.account.mapper.AccountEntryMapper;
import com.sb.web.account.mapper.AccountTagMapper; import com.sb.web.account.mapper.AccountTagMapper;
import com.sb.web.account.mapper.WalletMapper; import com.sb.web.account.mapper.WalletMapper;
import com.sb.web.auth.dto.SessionUser;
import com.sb.web.auth.mapper.MemberMapper;
import com.sb.web.common.exception.ApiException; import com.sb.web.common.exception.ApiException;
import com.sb.web.point.PointService; import com.sb.web.point.PointService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -51,6 +53,7 @@ public class AccountService {
private final CardNotificationParser notificationParser; private final CardNotificationParser notificationParser;
private final AiEntryParser aiEntryParser; private final AiEntryParser aiEntryParser;
private final PointService pointService; private final PointService pointService;
private final MemberMapper memberMapper;
/* ===================== 항목 ===================== */ /* ===================== 항목 ===================== */
@@ -341,11 +344,26 @@ public class AccountService {
.build(); .build();
} }
/** 통계 화면 집계 → AI 재무 코멘트. 집계만 JSON 으로 직렬화해 전달(거래 원문 미전송). 미설정/실패 시 빈 코멘트. */ /** 통계 화면 집계 → AI 재무 코멘트.
public com.sb.web.account.dto.AiCommentResponse aiComment(com.sb.web.account.dto.AiCommentRequest req) { * - PREMIUM: 무제한.
* - FREE + ai_stat_credits > 0: 크레딧 1 차감 후 호출.
* - FREE + credits == 0: 402 에러(프론트가 상점으로 유도).
*/
@Transactional
public com.sb.web.account.dto.AiCommentResponse aiComment(
com.sb.web.account.dto.AiCommentRequest req, SessionUser current) {
if ("FREE".equals(current.getPlan())) {
int credits = memberMapper.getAiStatCredits(current.getId());
if (credits <= 0) {
throw new ApiException(HttpStatus.PAYMENT_REQUIRED, "AI_STAT_CREDIT_EMPTY");
}
memberMapper.decrementAiStatCredit(current.getId());
}
try { try {
String json = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(req); String json = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(req);
return aiEntryParser.financeComment(json); return aiEntryParser.financeComment(json);
} catch (ApiException e) {
throw e;
} catch (Exception e) { } catch (Exception e) {
return com.sb.web.account.dto.AiCommentResponse.builder() return com.sb.web.account.dto.AiCommentResponse.builder()
.bullets(java.util.List.of()).tips(java.util.List.of()).build(); .bullets(java.util.List.of()).tips(java.util.List.of()).build();
@@ -561,9 +579,9 @@ public class AccountService {
public static final int FREE_WALLET_BASE_LIMIT = 2; public static final int FREE_WALLET_BASE_LIMIT = 2;
public static final int FREE_WALLET_MAX_LIMIT = 5; public static final int FREE_WALLET_MAX_LIMIT = 5;
/** 무료 회원의 해당 타입 등록 한도 = 기본 2 + 구매한 영구 보너스 슬롯(추후 상점), 최대 5. */ /** 무료 회원의 해당 타입 등록 한도 = 기본 2 + 포인트 상점 구매 슬롯, 최대 5. */
private int freeWalletLimit(Long memberId, String type) { private int freeWalletLimit(Long memberId, String type) {
int bonus = 0; // TODO: 포인트 상점 구현 시 회원×종류별 구매 슬롯 수를 조회해 더함 int bonus = memberMapper.getExtraWalletSlots(memberId);
return Math.min(FREE_WALLET_BASE_LIMIT + bonus, FREE_WALLET_MAX_LIMIT); return Math.min(FREE_WALLET_BASE_LIMIT + bonus, FREE_WALLET_MAX_LIMIT);
} }
@@ -0,0 +1,71 @@
package com.sb.web.account.service;
import com.sb.web.account.dto.ShopStatusResponse;
import com.sb.web.auth.mapper.MemberMapper;
import com.sb.web.point.PointService;
import com.sb.web.point.mapper.PointMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
@Service
@RequiredArgsConstructor
public class ShopService {
static final int MAX_EXTRA_SLOTS = 3;
static final int WALLET_SLOT_PRICE = 500;
static final int AI_STAT_PRICE = 1000;
static final int AI_STAT_PACK_SIZE = 10;
private static final String REASON_SHOP_WALLET_SLOT = "SHOP_WALLET_SLOT";
private static final String REASON_SHOP_AI_STAT = "SHOP_AI_STAT";
private final MemberMapper memberMapper;
private final PointMapper pointMapper;
public ShopStatusResponse getStatus(Long memberId) {
return ShopStatusResponse.builder()
.extraWalletSlots(memberMapper.getExtraWalletSlots(memberId))
.aiStatCredits(memberMapper.getAiStatCredits(memberId))
.maxExtraSlots(MAX_EXTRA_SLOTS)
.walletSlotPrice(WALLET_SLOT_PRICE)
.aiStatPrice(AI_STAT_PRICE)
.aiStatPackSize(AI_STAT_PACK_SIZE)
.build();
}
@Transactional
public ShopStatusResponse buyWalletSlot(Long memberId) {
int slots = memberMapper.getExtraWalletSlots(memberId);
if (slots >= MAX_EXTRA_SLOTS) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "SHOP_WALLET_SLOT_MAX");
}
long points = pointService(memberId);
if (points < WALLET_SLOT_PRICE) {
throw new ResponseStatusException(HttpStatus.PAYMENT_REQUIRED, "SHOP_NOT_ENOUGH_POINTS");
}
pointMapper.insertHistory(memberId, -WALLET_SLOT_PRICE, REASON_SHOP_WALLET_SLOT);
pointMapper.addPoints(memberId, -WALLET_SLOT_PRICE);
memberMapper.incrementExtraWalletSlots(memberId);
return getStatus(memberId);
}
@Transactional
public ShopStatusResponse buyAiStatPack(Long memberId) {
long points = pointService(memberId);
if (points < AI_STAT_PRICE) {
throw new ResponseStatusException(HttpStatus.PAYMENT_REQUIRED, "SHOP_NOT_ENOUGH_POINTS");
}
pointMapper.insertHistory(memberId, -AI_STAT_PRICE, REASON_SHOP_AI_STAT);
pointMapper.addPoints(memberId, -AI_STAT_PRICE);
memberMapper.addAiStatCredits(memberId, AI_STAT_PACK_SIZE);
return getStatus(memberId);
}
private long pointService(Long memberId) {
Long p = pointMapper.getPoints(memberId);
return p == null ? 0L : p;
}
}
@@ -73,4 +73,11 @@ public interface MemberMapper {
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);
// ── 포인트 상점 ──────────────────────────────────────────────
int getExtraWalletSlots(@Param("id") Long id);
int getAiStatCredits(@Param("id") Long id);
int incrementExtraWalletSlots(@Param("id") Long id);
int addAiStatCredits(@Param("id") Long id, @Param("amount") int amount);
int decrementAiStatCredit(@Param("id") Long id);
} }
@@ -39,7 +39,6 @@ public class WebConfig implements WebMvcConfigurer {
.addPathPatterns( .addPathPatterns(
"/api/account/stats", "/api/account/stats",
"/api/account/category-stats", "/api/account/category-stats",
"/api/account/ai-comment",
"/api/account/networth/trend", "/api/account/networth/trend",
"/api/account/budgets", "/api/account/budgets/**", "/api/account/budgets", "/api/account/budgets/**",
"/api/account/ocr", "/api/account/ocr/**", "/api/account/ocr", "/api/account/ocr/**",
+4
View File
@@ -65,6 +65,10 @@ CREATE TABLE IF NOT EXISTS iap_purchase (
KEY idx_iap_member (member_id) KEY idx_iap_member (member_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 포인트 상점: 추가 계좌 슬롯(무료 회원 500P/개, 최대 3개), AI 통계 크레딧(1000P → 10회)
ALTER TABLE member ADD COLUMN IF NOT EXISTS extra_wallet_slots INT NOT NULL DEFAULT 0 COMMENT '포인트 상점 구매 추가 계좌 슬롯 수 (최대 3)' AFTER points;
ALTER TABLE member ADD COLUMN IF NOT EXISTS ai_stat_credits INT NOT NULL DEFAULT 0 COMMENT '포인트 상점 구매 AI 통계 크레딧 잔여 횟수' AFTER extra_wallet_slots;
-- 포인트 적립/차감 이력 (감사 + 일일 지급 한도 계산용). -- 포인트 적립/차감 이력 (감사 + 일일 지급 한도 계산용).
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,
@@ -152,4 +152,21 @@
DELETE FROM member WHERE id = #{id} DELETE FROM member WHERE id = #{id}
</delete> </delete>
<!-- 포인트 상점 -->
<select id="getExtraWalletSlots" resultType="int">
SELECT COALESCE(extra_wallet_slots, 0) FROM member WHERE id = #{id}
</select>
<select id="getAiStatCredits" resultType="int">
SELECT COALESCE(ai_stat_credits, 0) FROM member WHERE id = #{id}
</select>
<update id="incrementExtraWalletSlots">
UPDATE member SET extra_wallet_slots = extra_wallet_slots + 1, updated_at = NOW() WHERE id = #{id}
</update>
<update id="addAiStatCredits">
UPDATE member SET ai_stat_credits = ai_stat_credits + #{amount}, updated_at = NOW() WHERE id = #{id}
</update>
<update id="decrementAiStatCredit">
UPDATE member SET ai_stat_credits = GREATEST(0, ai_stat_credits - 1), updated_at = NOW() WHERE id = #{id}
</update>
</mapper> </mapper>