From 8fec05775234e0c9913c52ee7ef263c69abfbc2c Mon Sep 17 00:00:00 2001 From: ByungCheol Date: Wed, 3 Jun 2026 18:51:30 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=B9=B4=EB=93=9C=20=EA=B2=B0=EC=A0=9C?= =?UTF-8?q?=20=EC=95=8C=EB=A6=BC=20=EC=9E=90=EB=8F=99=EC=9D=B8=EC=8B=9D(?= =?UTF-8?q?=EB=AF=B8=ED=99=95=EC=9D=B8=20=EB=82=B4=EC=97=AD)=20=EB=B0=B1?= =?UTF-8?q?=EC=97=94=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - account_entry pending/notif_key 컬럼 추가(멱등 마이그레이션) - CardNotificationParser: 카드사·금액·가맹점·승인/취소 파싱 - POST /entries/notification(미확인 지출 생성, 중복방지, 카드 자동매칭) - POST /entries/{id}/confirm(확정), GET /entries/pending/count Co-Authored-By: Claude Opus 4.8 --- .../account/controller/AccountController.java | 33 +++++ .../sb/web/account/domain/AccountEntry.java | 2 + .../web/account/dto/AccountEntryResponse.java | 2 + .../sb/web/account/dto/ConfirmRequest.java | 13 ++ .../web/account/dto/NotificationRequest.java | 19 +++ .../account/mapper/AccountEntryMapper.java | 11 ++ .../web/account/service/AccountService.java | 70 ++++++++++ .../service/CardNotificationParser.java | 130 ++++++++++++++++++ src/main/resources/db/account.sql | 6 + .../resources/mapper/AccountEntryMapper.xml | 31 ++++- 10 files changed, 314 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/sb/web/account/dto/ConfirmRequest.java create mode 100644 src/main/java/com/sb/web/account/dto/NotificationRequest.java create mode 100644 src/main/java/com/sb/web/account/service/CardNotificationParser.java 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 174970d..1dd5e39 100644 --- a/src/main/java/com/sb/web/account/controller/AccountController.java +++ b/src/main/java/com/sb/web/account/controller/AccountController.java @@ -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 pendingCount( + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return Map.of("count", accountService.countPending(current.getId())); + } + + /** 카드 결제 푸시 알림 → 미확인 지출 내역 생성 (네이티브 알림 리스너가 호출). 생성 안 되면 204 */ + @PostMapping("/entries/notification") + public ResponseEntity 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()); + } } diff --git a/src/main/java/com/sb/web/account/domain/AccountEntry.java b/src/main/java/com/sb/web/account/domain/AccountEntry.java index 48d7271..113d9e2 100644 --- a/src/main/java/com/sb/web/account/domain/AccountEntry.java +++ b/src/main/java/com/sb/web/account/domain/AccountEntry.java @@ -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; } diff --git a/src/main/java/com/sb/web/account/dto/AccountEntryResponse.java b/src/main/java/com/sb/web/account/dto/AccountEntryResponse.java index ebe091c..349c8f8 100644 --- a/src/main/java/com/sb/web/account/dto/AccountEntryResponse.java +++ b/src/main/java/com/sb/web/account/dto/AccountEntryResponse.java @@ -25,6 +25,7 @@ public class AccountEntryResponse { private Long toWalletId; private String toWalletName; private Integer installmentMonths; + private Boolean pending; private List tags; public static AccountEntryResponse from(AccountEntry e, List 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(); } diff --git a/src/main/java/com/sb/web/account/dto/ConfirmRequest.java b/src/main/java/com/sb/web/account/dto/ConfirmRequest.java new file mode 100644 index 0000000..27776c8 --- /dev/null +++ b/src/main/java/com/sb/web/account/dto/ConfirmRequest.java @@ -0,0 +1,13 @@ +package com.sb.web.account.dto; + +import lombok.Data; + +/** + * 미확인(알림 자동인식) 내역 확정 요청 — 분류·계좌 보정(선택). + */ +@Data +public class ConfirmRequest { + + private String category; + private Long walletId; +} diff --git a/src/main/java/com/sb/web/account/dto/NotificationRequest.java b/src/main/java/com/sb/web/account/dto/NotificationRequest.java new file mode 100644 index 0000000..3872368 --- /dev/null +++ b/src/main/java/com/sb/web/account/dto/NotificationRequest.java @@ -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; +} diff --git a/src/main/java/com/sb/web/account/mapper/AccountEntryMapper.java b/src/main/java/com/sb/web/account/mapper/AccountEntryMapper.java index f1aa1c1..c158f98 100644 --- a/src/main/java/com/sb/web/account/mapper/AccountEntryMapper.java +++ b/src/main/java/com/sb/web/account/mapper/AccountEntryMapper.java @@ -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 findDistinctCategories(@Param("memberId") Long memberId, @Param("type") String type); 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 b589443..8b3ab88 100644 --- a/src/main/java/com/sb/web/account/service/AccountService.java +++ b/src/main/java/com/sb/web/account/service/AccountService.java @@ -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건 생성 */ diff --git a/src/main/java/com/sb/web/account/service/CardNotificationParser.java b/src/main/java/com/sb/web/account/service/CardNotificationParser.java new file mode 100644 index 0000000..51caa31 --- /dev/null +++ b/src/main/java/com/sb/web/account/service/CardNotificationParser.java @@ -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 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; + } +} diff --git a/src/main/resources/db/account.sql b/src/main/resources/db/account.sql index 1b3ae8b..68707a4 100644 --- a/src/main/resources/db/account.sql +++ b/src/main/resources/db/account.sql @@ -40,6 +40,8 @@ CREATE TABLE IF NOT EXISTS account_entry ( wallet_id BIGINT NULL COMMENT '발생/출금 계좌(wallet.id)', to_wallet_id BIGINT NULL COMMENT '이체 입금 계좌(TRANSFER)', installment_months INT NULL COMMENT '카드 할부 개월수(2~24, 일시불은 NULL)', + pending TINYINT(1) NOT NULL DEFAULT 0 COMMENT '확인 필요(미확정) 여부', + notif_key VARCHAR(160) NULL COMMENT '알림 자동인식 중복방지 키', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), @@ -50,6 +52,10 @@ CREATE TABLE IF NOT EXISTS account_entry ( ALTER TABLE account_entry ADD COLUMN IF NOT EXISTS wallet_id BIGINT NULL; ALTER TABLE account_entry ADD COLUMN IF NOT EXISTS to_wallet_id BIGINT NULL; ALTER TABLE account_entry ADD COLUMN IF NOT EXISTS installment_months INT NULL; +-- 카드 결제 알림 자동인식: 미확인(확인 필요) 플래그 + 중복방지 키 +ALTER TABLE account_entry ADD COLUMN IF NOT EXISTS pending TINYINT(1) NOT NULL DEFAULT 0 COMMENT '확인 필요(미확정) 여부'; +ALTER TABLE account_entry ADD COLUMN IF NOT EXISTS notif_key VARCHAR(160) NULL COMMENT '알림 자동인식 중복방지 키'; +ALTER TABLE account_entry ADD INDEX IF NOT EXISTS idx_entry_notif (member_id, notif_key); -- 가계부 태그 (사용자별 관리 — 게시판 태그와 분리) CREATE TABLE IF NOT EXISTS account_tag ( diff --git a/src/main/resources/mapper/AccountEntryMapper.xml b/src/main/resources/mapper/AccountEntryMapper.xml index 50d56ec..5317807 100644 --- a/src/main/resources/mapper/AccountEntryMapper.xml +++ b/src/main/resources/mapper/AccountEntryMapper.xml @@ -16,6 +16,8 @@ + + @@ -29,7 +31,7 @@ e.id, e.member_id, e.entry_date, e.type, e.category, e.amount, e.memo, e.wallet_id, w.name AS wallet_name, e.to_wallet_id, tw.name AS to_wallet_name, - e.installment_months, + e.installment_months, e.pending, e.notif_key, e.created_at, e.updated_at @@ -127,9 +129,11 @@ INSERT INTO account_entry (member_id, entry_date, type, category, amount, memo, - wallet_id, to_wallet_id, installment_months, created_at, updated_at) + wallet_id, to_wallet_id, installment_months, pending, notif_key, + created_at, updated_at) VALUES (#{memberId}, #{entryDate}, #{type}, #{category}, #{amount}, #{memo}, - #{walletId}, #{toWalletId}, #{installmentMonths}, NOW(), NOW()) + #{walletId}, #{toWalletId}, #{installmentMonths}, + COALESCE(#{pending}, 0), #{notifKey}, NOW(), NOW()) @@ -144,6 +148,27 @@ DELETE FROM account_entry WHERE id = #{id} AND member_id = #{memberId} + + + + + + + UPDATE account_entry + SET pending = 0, + category = #{category}, + wallet_id = #{walletId}, + updated_at = NOW() + WHERE id = #{id} AND member_id = #{memberId} AND pending = 1 + +