- 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:
@@ -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);
|
||||
}
|
||||
|
||||
/** 미확인 내역 확정 (분류/계좌 보정) */
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 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(
|
||||
"/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/**",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -152,4 +152,21 @@
|
||||
DELETE FROM member WHERE id = #{id}
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user