diff --git a/src/main/java/com/sb/web/account/controller/AccountController.java b/src/main/java/com/sb/web/account/controller/AccountController.java index ac58ae4..2f656ba 100644 --- a/src/main/java/com/sb/web/account/controller/AccountController.java +++ b/src/main/java/com/sb/web/account/controller/AccountController.java @@ -252,12 +252,12 @@ public class AccountController { return accountService.parseText(req); } - /** 통계 화면 집계 → AI 재무 코멘트(유료). 집계만 전송, 거래 원문 미전송. */ + /** 통계 화면 집계 → AI 재무 코멘트. PREMIUM 무제한, FREE 는 크레딧 차감. */ @PostMapping("/ai-comment") public com.sb.web.account.dto.AiCommentResponse aiComment( @RequestBody com.sb.web.account.dto.AiCommentRequest req, @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { - return accountService.aiComment(req); + return accountService.aiComment(req, current); } /** 미확인 내역 확정 (분류/계좌 보정) */ diff --git a/src/main/java/com/sb/web/account/controller/BackupController.java b/src/main/java/com/sb/web/account/controller/BackupController.java index 99da84c..e8938bf 100644 --- a/src/main/java/com/sb/web/account/controller/BackupController.java +++ b/src/main/java/com/sb/web/account/controller/BackupController.java @@ -6,10 +6,11 @@ import com.sb.web.auth.dto.SessionUser; import com.sb.web.auth.web.AuthInterceptor; import lombok.RequiredArgsConstructor; 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.RequestAttribute; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestAttribute; import org.springframework.web.bind.annotation.RestController; /** diff --git a/src/main/java/com/sb/web/account/controller/ShopController.java b/src/main/java/com/sb/web/account/controller/ShopController.java new file mode 100644 index 0000000..10161a3 --- /dev/null +++ b/src/main/java/com/sb/web/account/controller/ShopController.java @@ -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 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"); + }; + } +} diff --git a/src/main/java/com/sb/web/account/dto/ShopStatusResponse.java b/src/main/java/com/sb/web/account/dto/ShopStatusResponse.java new file mode 100644 index 0000000..6c679a5 --- /dev/null +++ b/src/main/java/com/sb/web/account/dto/ShopStatusResponse.java @@ -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; +} diff --git a/src/main/java/com/sb/web/account/service/AccountService.java b/src/main/java/com/sb/web/account/service/AccountService.java index 90d52ac..876c074 100644 --- a/src/main/java/com/sb/web/account/service/AccountService.java +++ b/src/main/java/com/sb/web/account/service/AccountService.java @@ -21,6 +21,8 @@ import com.sb.web.account.dto.WalletResponse; import com.sb.web.account.mapper.AccountEntryMapper; import com.sb.web.account.mapper.AccountTagMapper; 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.point.PointService; import lombok.RequiredArgsConstructor; @@ -51,6 +53,7 @@ public class AccountService { private final CardNotificationParser notificationParser; private final AiEntryParser aiEntryParser; private final PointService pointService; + private final MemberMapper memberMapper; /* ===================== 항목 ===================== */ @@ -341,11 +344,26 @@ public class AccountService { .build(); } - /** 통계 화면 집계 → AI 재무 코멘트. 집계만 JSON 으로 직렬화해 전달(거래 원문 미전송). 미설정/실패 시 빈 코멘트. */ - public com.sb.web.account.dto.AiCommentResponse aiComment(com.sb.web.account.dto.AiCommentRequest req) { + /** 통계 화면 집계 → AI 재무 코멘트. + * - 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 { String json = new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(req); return aiEntryParser.financeComment(json); + } catch (ApiException e) { + throw e; } catch (Exception e) { return com.sb.web.account.dto.AiCommentResponse.builder() .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_MAX_LIMIT = 5; - /** 무료 회원의 해당 타입 등록 한도 = 기본 2 + 구매한 영구 보너스 슬롯(추후 상점), 최대 5. */ + /** 무료 회원의 해당 타입 등록 한도 = 기본 2 + 포인트 상점 구매 슬롯, 최대 5. */ 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); } diff --git a/src/main/java/com/sb/web/account/service/ShopService.java b/src/main/java/com/sb/web/account/service/ShopService.java new file mode 100644 index 0000000..b76a781 --- /dev/null +++ b/src/main/java/com/sb/web/account/service/ShopService.java @@ -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; + } +} 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 331336e..5eb4162 100644 --- a/src/main/java/com/sb/web/auth/mapper/MemberMapper.java +++ b/src/main/java/com/sb/web/auth/mapper/MemberMapper.java @@ -73,4 +73,11 @@ public interface MemberMapper { int updateStatus(@Param("id") Long id, @Param("status") String status); 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); } 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 1da8afe..d51ee0e 100644 --- a/src/main/java/com/sb/web/common/config/WebConfig.java +++ b/src/main/java/com/sb/web/common/config/WebConfig.java @@ -39,7 +39,6 @@ public class WebConfig implements WebMvcConfigurer { .addPathPatterns( "/api/account/stats", "/api/account/category-stats", - "/api/account/ai-comment", "/api/account/networth/trend", "/api/account/budgets", "/api/account/budgets/**", "/api/account/ocr", "/api/account/ocr/**", diff --git a/src/main/resources/db/member.sql b/src/main/resources/db/member.sql index bf44ec8..5c11a09 100644 --- a/src/main/resources/db/member.sql +++ b/src/main/resources/db/member.sql @@ -65,6 +65,10 @@ CREATE TABLE IF NOT EXISTS iap_purchase ( KEY idx_iap_member (member_id) ) 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 ( id BIGINT NOT NULL AUTO_INCREMENT, diff --git a/src/main/resources/mapper/MemberMapper.xml b/src/main/resources/mapper/MemberMapper.xml index 843c771..1fee085 100644 --- a/src/main/resources/mapper/MemberMapper.xml +++ b/src/main/resources/mapper/MemberMapper.xml @@ -152,4 +152,21 @@ DELETE FROM member WHERE id = #{id} + + + + + UPDATE member SET extra_wallet_slots = extra_wallet_slots + 1, updated_at = NOW() WHERE id = #{id} + + + UPDATE member SET ai_stat_credits = ai_stat_credits + #{amount}, updated_at = NOW() WHERE id = #{id} + + + UPDATE member SET ai_stat_credits = GREATEST(0, ai_stat_credits - 1), updated_at = NOW() WHERE id = #{id} + +