- account_entry pending/notif_key 컬럼 추가(멱등 마이그레이션)
- CardNotificationParser: 카드사·금액·가맹점·승인/취소 파싱
- POST /entries/notification(미확인 지출 생성, 중복방지, 카드 자동매칭)
- POST /entries/{id}/confirm(확정), GET /entries/pending/count
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,8 @@ import com.sb.web.account.dto.AccountSummary;
|
||||
import com.sb.web.account.dto.AccountTagRequest;
|
||||
import com.sb.web.account.dto.AccountTagResponse;
|
||||
import com.sb.web.account.dto.CategoryStat;
|
||||
import com.sb.web.account.dto.ConfirmRequest;
|
||||
import com.sb.web.account.dto.NotificationRequest;
|
||||
import com.sb.web.account.dto.NetWorthPoint;
|
||||
import com.sb.web.account.dto.NetWorthResponse;
|
||||
import com.sb.web.account.dto.PeriodStat;
|
||||
@@ -23,6 +25,7 @@ import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 가계부 API. (/api/account/** — 로그인 필요, 본인 데이터만 접근)
|
||||
@@ -202,4 +205,34 @@ public class AccountController {
|
||||
accountService.delete(id, current.getId());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
/* ===== 카드 결제 알림 자동인식 ===== */
|
||||
|
||||
/** 미확인(확인 필요) 내역 개수 */
|
||||
@GetMapping("/entries/pending/count")
|
||||
public Map<String, Integer> pendingCount(
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return Map.of("count", accountService.countPending(current.getId()));
|
||||
}
|
||||
|
||||
/** 카드 결제 푸시 알림 → 미확인 지출 내역 생성 (네이티브 알림 리스너가 호출). 생성 안 되면 204 */
|
||||
@PostMapping("/entries/notification")
|
||||
public ResponseEntity<AccountEntryResponse> fromNotification(
|
||||
@RequestBody NotificationRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
AccountEntryResponse created = accountService.createFromNotification(req, current.getId());
|
||||
if (created == null) {
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(created);
|
||||
}
|
||||
|
||||
/** 미확인 내역 확정 (분류/계좌 보정) */
|
||||
@PostMapping("/entries/{id}/confirm")
|
||||
public AccountEntryResponse confirm(
|
||||
@PathVariable Long id,
|
||||
@RequestBody(required = false) ConfirmRequest req,
|
||||
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
|
||||
return accountService.confirm(id, req, current.getId());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ public class AccountEntry implements Serializable {
|
||||
private Long toWalletId; // 이체 입금 계좌
|
||||
private String toWalletName;
|
||||
private Integer installmentMonths; // 카드 할부 개월수(2~24, 일시불은 null)
|
||||
private Boolean pending; // 확인 필요(미확정) 여부 — 알림 자동인식 건
|
||||
private String notifKey; // 알림 자동인식 중복방지 키
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ public class AccountEntryResponse {
|
||||
private Long toWalletId;
|
||||
private String toWalletName;
|
||||
private Integer installmentMonths;
|
||||
private Boolean pending;
|
||||
private List<String> tags;
|
||||
|
||||
public static AccountEntryResponse from(AccountEntry e, List<String> tags) {
|
||||
@@ -40,6 +41,7 @@ public class AccountEntryResponse {
|
||||
.toWalletId(e.getToWalletId())
|
||||
.toWalletName(e.getToWalletName())
|
||||
.installmentMonths(e.getInstallmentMonths())
|
||||
.pending(Boolean.TRUE.equals(e.getPending()))
|
||||
.tags(tags)
|
||||
.build();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 미확인(알림 자동인식) 내역 확정 요청 — 분류·계좌 보정(선택).
|
||||
*/
|
||||
@Data
|
||||
public class ConfirmRequest {
|
||||
|
||||
private String category;
|
||||
private Long walletId;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.sb.web.account.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 카드 결제 푸시 알림 원문 (네이티브 알림 리스너 → 백엔드).
|
||||
*/
|
||||
@Data
|
||||
public class NotificationRequest {
|
||||
|
||||
/** 알림을 띄운 앱 패키지명 (예: 카드사 앱) */
|
||||
private String app;
|
||||
|
||||
/** 알림 제목 */
|
||||
private String title;
|
||||
|
||||
/** 알림 본문 */
|
||||
private String text;
|
||||
}
|
||||
@@ -59,6 +59,17 @@ public interface AccountEntryMapper {
|
||||
|
||||
int delete(@Param("id") Long id, @Param("memberId") Long memberId);
|
||||
|
||||
/* ----- 알림 자동인식(미확인) ----- */
|
||||
/** 중복방지 키로 기존 알림 내역 조회 */
|
||||
AccountEntry findByMemberAndNotifKey(@Param("memberId") Long memberId, @Param("notifKey") String notifKey);
|
||||
|
||||
/** 미확인(확인 필요) 내역 개수 */
|
||||
int countPending(@Param("memberId") Long memberId);
|
||||
|
||||
/** 미확인 내역 확정 (pending=0, 분류/계좌 보정) */
|
||||
int confirm(@Param("id") Long id, @Param("memberId") Long memberId,
|
||||
@Param("category") String category, @Param("walletId") Long walletId);
|
||||
|
||||
/** 기존 내역의 (type별) 사용된 분류명 distinct (분류 불러오기용) */
|
||||
List<String> findDistinctCategories(@Param("memberId") Long memberId, @Param("type") String type);
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import com.sb.web.account.domain.Wallet;
|
||||
import com.sb.web.account.dto.AccountEntryRequest;
|
||||
import com.sb.web.account.dto.AccountEntryResponse;
|
||||
import com.sb.web.account.dto.AccountSummary;
|
||||
import com.sb.web.account.dto.ConfirmRequest;
|
||||
import com.sb.web.account.dto.NotificationRequest;
|
||||
import com.sb.web.account.dto.AccountTagRequest;
|
||||
import com.sb.web.account.dto.AccountTagResponse;
|
||||
import com.sb.web.account.dto.CategoryStat;
|
||||
@@ -43,6 +45,7 @@ public class AccountService {
|
||||
private final AccountTagMapper tagMapper;
|
||||
private final WalletMapper walletMapper;
|
||||
private final InvestService investService;
|
||||
private final CardNotificationParser notificationParser;
|
||||
|
||||
/* ===================== 항목 ===================== */
|
||||
|
||||
@@ -202,6 +205,73 @@ public class AccountService {
|
||||
mapper.delete(id, memberId);
|
||||
}
|
||||
|
||||
/* ===================== 카드 결제 알림 자동인식 ===================== */
|
||||
|
||||
/** 미확인(확인 필요) 내역 개수 */
|
||||
public int countPending(Long memberId) {
|
||||
return mapper.countPending(memberId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 카드 결제 푸시 알림 → 미확인(pending) 지출 내역 생성.
|
||||
* 결제 알림이 아니거나 중복이면 null 반환(생성 안 함). 취소 알림도 일단 무시(v1).
|
||||
*/
|
||||
@Transactional
|
||||
public AccountEntryResponse createFromNotification(NotificationRequest req, Long memberId) {
|
||||
CardNotificationParser.Parsed p =
|
||||
notificationParser.parse(req.getTitle(), req.getText(), java.time.LocalDate.now());
|
||||
if (!p.isCard() || p.isCanceled() || p.getAmount() <= 0) {
|
||||
return null; // 결제 승인 알림 아님 / 취소 → 무시
|
||||
}
|
||||
// 중복방지
|
||||
if (p.getNotifKey() != null && mapper.findByMemberAndNotifKey(memberId, p.getNotifKey()) != null) {
|
||||
return null;
|
||||
}
|
||||
// 카드사 → 등록된 카드(CARD) 매칭
|
||||
Long walletId = matchCardWalletId(p.getIssuer(), memberId);
|
||||
|
||||
AccountEntry entry = AccountEntry.builder()
|
||||
.memberId(memberId)
|
||||
.entryDate(p.getDate() != null ? p.getDate() : java.time.LocalDate.now())
|
||||
.type("EXPENSE")
|
||||
.category(null)
|
||||
.amount(p.getAmount())
|
||||
.memo(p.getMerchant())
|
||||
.walletId(walletId)
|
||||
.toWalletId(null)
|
||||
.installmentMonths(null)
|
||||
.pending(true)
|
||||
.notifKey(p.getNotifKey())
|
||||
.build();
|
||||
mapper.insert(entry);
|
||||
return toResponse(mapper.findByIdAndMember(entry.getId(), memberId));
|
||||
}
|
||||
|
||||
/** 미확인 내역 확정 (분류/계좌 보정 후 pending 해제) */
|
||||
@Transactional
|
||||
public AccountEntryResponse confirm(Long id, ConfirmRequest req, Long memberId) {
|
||||
AccountEntry e = mustFind(id, memberId);
|
||||
if (!Boolean.TRUE.equals(e.getPending())) {
|
||||
return toResponse(e); // 이미 확정된 건
|
||||
}
|
||||
String category = req != null ? req.getCategory() : null;
|
||||
Long walletId = req != null ? req.getWalletId() : null;
|
||||
mapper.confirm(id, memberId, category, walletId);
|
||||
return toResponse(mapper.findByIdAndMember(id, memberId));
|
||||
}
|
||||
|
||||
/** 카드사명으로 등록된 CARD 지갑 id 찾기 (issuer/name 부분일치) */
|
||||
private Long matchCardWalletId(String issuer, Long memberId) {
|
||||
if (issuer == null) return null;
|
||||
return walletMapper.findByMember(memberId).stream()
|
||||
.filter(w -> "CARD".equals(w.getType()))
|
||||
.filter(w -> (w.getIssuer() != null && w.getIssuer().contains(issuer))
|
||||
|| (w.getName() != null && w.getName().contains(issuer)))
|
||||
.map(Wallet::getId)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/* ===================== 상환/납부 ===================== */
|
||||
|
||||
/** 대출/카드 상환·납부: 원금은 이체(부채↓), 이자는 지출로 분리해 2건 생성 */
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.sb.web.account.service;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 카드사 결제 푸시 알림 텍스트 파싱.
|
||||
* 카드사마다 문구가 달라 휴리스틱으로 카드사·금액·가맹점·승인/취소를 추출한다.
|
||||
* (서버에 두어 APK 재빌드 없이 규칙 보강 가능)
|
||||
*/
|
||||
@Component
|
||||
public class CardNotificationParser {
|
||||
|
||||
// 감지할 카드사 (긴 이름 우선)
|
||||
private static final List<String> ISSUERS = List.of(
|
||||
"KB국민", "국민", "삼성", "신한", "현대", "롯데", "하나", "우리", "비씨", "BC",
|
||||
"농협", "NH", "씨티", "카카오뱅크", "카카오", "토스", "수협", "광주", "전북", "제주"
|
||||
);
|
||||
|
||||
private static final Pattern AMOUNT = Pattern.compile("([0-9]{1,3}(?:,[0-9]{3})+|[0-9]{4,})\\s*원");
|
||||
private static final Pattern DATE_MD = Pattern.compile("(\\d{1,2})[./](\\d{1,2})");
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
public static class Parsed {
|
||||
private boolean card; // 카드 결제 알림으로 인식되었는가
|
||||
private boolean canceled; // 취소/취소승인
|
||||
private String issuer; // 감지된 카드사 (없으면 null)
|
||||
private long amount; // 금액(원)
|
||||
private String merchant; // 가맹점(추정)
|
||||
private LocalDate date; // 거래일(추정, 없으면 null)
|
||||
private String notifKey; // 중복방지 키
|
||||
}
|
||||
|
||||
/** 카드 결제 알림 파싱. card=false 면 결제 알림이 아님(무시 대상). */
|
||||
public Parsed parse(String title, String text, LocalDate today) {
|
||||
String raw = ((title == null ? "" : title) + "\n" + (text == null ? "" : text)).trim();
|
||||
if (raw.isBlank()) return notCard();
|
||||
|
||||
boolean approved = raw.contains("승인") || raw.contains("결제") || raw.contains("출금");
|
||||
boolean canceled = raw.contains("취소") || raw.contains("환불");
|
||||
Matcher am = AMOUNT.matcher(raw);
|
||||
if (!am.find() || (!approved && !canceled)) {
|
||||
return notCard();
|
||||
}
|
||||
long amount = Long.parseLong(am.group(1).replace(",", ""));
|
||||
|
||||
String issuer = detectIssuer(raw);
|
||||
String merchant = guessMerchant(raw);
|
||||
LocalDate date = guessDate(raw, today);
|
||||
|
||||
String notifKey = (issuer == null ? "?" : issuer) + "|" + amount + "|"
|
||||
+ (merchant == null ? "" : merchant) + "|" + date + "|" + (canceled ? "C" : "A");
|
||||
|
||||
return Parsed.builder()
|
||||
.card(true)
|
||||
.canceled(canceled)
|
||||
.issuer(issuer)
|
||||
.amount(amount)
|
||||
.merchant(merchant)
|
||||
.date(date)
|
||||
.notifKey(notifKey)
|
||||
.build();
|
||||
}
|
||||
|
||||
private Parsed notCard() {
|
||||
return Parsed.builder().card(false).build();
|
||||
}
|
||||
|
||||
private String detectIssuer(String raw) {
|
||||
for (String c : ISSUERS) {
|
||||
if (raw.contains(c)) return c;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private LocalDate guessDate(String raw, LocalDate today) {
|
||||
Matcher m = DATE_MD.matcher(raw);
|
||||
while (m.find()) {
|
||||
int mo = Integer.parseInt(m.group(1));
|
||||
int d = Integer.parseInt(m.group(2));
|
||||
if (mo >= 1 && mo <= 12 && d >= 1 && d <= 31) {
|
||||
try {
|
||||
LocalDate cand = LocalDate.of(today.getYear(), mo, d);
|
||||
// 미래 날짜(연말연초 경계)면 작년으로
|
||||
return cand.isAfter(today.plusDays(1)) ? cand.minusYears(1) : cand;
|
||||
} catch (Exception ignore) {
|
||||
// 다음 후보
|
||||
}
|
||||
}
|
||||
}
|
||||
return today;
|
||||
}
|
||||
|
||||
/**
|
||||
* 가맹점 추정: 잡음(카드사·승인문구·금액·날짜·시간·마스킹 이름 등)을 제거한 뒤
|
||||
* 남은 한글/영문 토큰 중 가장 그럴듯한 것을 고른다. (완벽하지 않음 — 사용자가 확정 시 수정)
|
||||
*/
|
||||
private String guessMerchant(String raw) {
|
||||
String s = raw
|
||||
.replaceAll("\\[?\\s*Web발신\\s*]?", " ")
|
||||
.replaceAll("[0-9]{1,3}(?:,[0-9]{3})+\\s*원|[0-9]{4,}\\s*원", " ") // 금액
|
||||
.replaceAll("\\d{1,2}[:시]\\d{2}분?", " ") // 시간
|
||||
.replaceAll("\\d{1,2}[./]\\d{1,2}", " ") // 날짜
|
||||
.replaceAll("\\([^)]*\\)", " ") // 괄호(카드 끝번호 등)
|
||||
.replaceAll("승인취소|취소승인|승인|결제|취소|환불|일시불|할부|누적|잔액|출금|입금|체크|신용", " ")
|
||||
.replaceAll("[가-힣]\\*+[가-힣]?", " "); // 마스킹 이름(홍*동)
|
||||
for (String c : ISSUERS) {
|
||||
s = s.replace(c, " ");
|
||||
}
|
||||
s = s.replace("카드", " ");
|
||||
String[] tokens = s.trim().split("\\s+");
|
||||
String best = null;
|
||||
for (String t : tokens) {
|
||||
String tok = t.replaceAll("[^0-9A-Za-z가-힣]", "").trim();
|
||||
if (tok.length() < 2 || tok.length() > 20) continue;
|
||||
if (tok.matches("\\d+")) continue;
|
||||
if (!tok.matches(".*[가-힣A-Za-z].*")) continue;
|
||||
// 더 긴(구체적인) 토큰을 가맹점으로 선호
|
||||
if (best == null || tok.length() > best.length()) best = tok;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user