From 120e042971114c69391d1f1ad41b91bf5f226e56 Mon Sep 17 00:00:00 2001 From: ByungCheol Date: Fri, 12 Jun 2026 22:15:54 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EA=B4=91=EA=B3=A0=20=ED=91=B8=EC=8B=9C?= =?UTF-8?q?=C2=B7=EB=AC=B8=EC=9E=90=20=EC=98=A4=EB=93=B1=EB=A1=9D=20?= =?UTF-8?q?=EC=B0=A8=EB=8B=A8=20=EA=B0=95=ED=99=94=20(=EC=A3=BD=EC=9D=80?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기존 광고 차단(if !strong && AD_HINT)은 강신호가 보장된 지점이라 항상 false → 한 번도 실행되지 않는 죽은 코드였음. 광고어가 섞여도 승인/입금 등 신호가 있으면 그대로 등록되던 원인. - 광고어를 2단계로 분리: - AD_BLOCK(이벤트·쿠폰·혜택·당첨 등): 거래신호가 있어도 무조건 제외 - AD_WEAK(적립·포인트·할인 등): '결제'만 있고 명확한 거래신호가 없을 때 제외 (실거래 영수증의 '포인트 적립'은 보존) - '결제'는 광고 문구에도 흔해 약신호(weakApproval)로 강등 - 파서 단위테스트 +4 (광고+강신호 차단, 실거래 포인트 보존, 결제단독 광고/정상) Co-Authored-By: Claude Opus 4.8 --- .../service/CardNotificationParser.java | 29 +++++++++++----- .../service/CardNotificationParserTest.java | 33 +++++++++++++++++++ 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/sb/web/account/service/CardNotificationParser.java b/src/main/java/com/sb/web/account/service/CardNotificationParser.java index 68b8991..3997741 100644 --- a/src/main/java/com/sb/web/account/service/CardNotificationParser.java +++ b/src/main/java/com/sb/web/account/service/CardNotificationParser.java @@ -29,9 +29,15 @@ public class CardNotificationParser { 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})"); - // 광고/이벤트 푸시에 흔한 표현 — 강한 신호(승인/입금/출금/이체/취소/환불)가 없으면 무시. - private static final Pattern AD_HINT = Pattern.compile( - "이벤트|광고|혜택|쿠폰|할인|적립|포인트|캐시백|마일리지|리워드|당첨|응모|추첨|프로모션|배너|무료|증정|모집"); + // 명백한 광고/이벤트 표현 — 거래신호(승인/입출금/취소)가 있어도 무조건 광고로 보고 제외. + // (실제 카드 승인/은행 입출금 알림에는 거의 등장하지 않는 표현들) + private static final Pattern AD_BLOCK = Pattern.compile( + "이벤트|광고|쿠폰|당첨|응모|추첨|프로모션|배너|모집|혜택|초대|수신거부|마케팅|사은품|경품|세일|" + + "바로가기|자세히\\s*보기|클릭|구독|뉴스레터|안내문자|광고성"); + // 약한 광고 표현 — 명확한 거래신호 없이 '결제'만 있을 때만 광고로 간주. + // (실거래 영수증/알림에도 '포인트 적립' 등이 붙는 경우가 있어 무조건 차단하지 않음) + private static final Pattern AD_WEAK = Pattern.compile( + "적립|포인트|캐시백|마일리지|리워드|무료|증정|할인"); @Getter @Builder @@ -54,15 +60,22 @@ public class CardNotificationParser { boolean deposit = raw.contains("입금"); boolean withdraw = raw.contains("출금") || raw.contains("이체") || raw.contains("송금"); boolean refund = raw.contains("취소") || raw.contains("환불"); - boolean approval = raw.contains("승인") || raw.contains("결제") || raw.contains("일시불") || raw.contains("할부"); + boolean approval = raw.contains("승인") || raw.contains("일시불") || raw.contains("할부"); + // '결제'는 광고 문구("결제하고 적립")에도 흔해 약신호로만 취급(단독으론 광고 의심) + boolean weakApproval = raw.contains("결제"); Matcher am = AMOUNT.matcher(raw); - boolean strong = approval || deposit || withdraw || refund; - if (!am.find() || !strong) { + boolean realSignal = approval || deposit || withdraw || refund; // 명확한 거래신호 + boolean anySignal = realSignal || weakApproval; + if (!am.find() || !anySignal) { return notRecognized(); } - // 광고/이벤트 차단: (강신호는 위에서 보장되지만) 광고성 표현이 섞인 약한 케이스 방지용 - if (!strong && AD_HINT.matcher(raw).find()) { + // 1) 명백한 광고/이벤트 표현이면 거래신호가 있어도 무시(광고 푸시·문자 오등록 방지) + if (AD_BLOCK.matcher(raw).find()) { + return notRecognized(); + } + // 2) 약한 광고 표현(적립/포인트/할인 등)은 명확한 거래신호 없이 '결제'만 있으면 광고로 간주 + if (!realSignal && AD_WEAK.matcher(raw).find()) { return notRecognized(); } long amount = Long.parseLong(am.group(1).replace(",", "")); diff --git a/src/test/java/com/sb/web/account/service/CardNotificationParserTest.java b/src/test/java/com/sb/web/account/service/CardNotificationParserTest.java index 443ef60..9f9a572 100644 --- a/src/test/java/com/sb/web/account/service/CardNotificationParserTest.java +++ b/src/test/java/com/sb/web/account/service/CardNotificationParserTest.java @@ -66,6 +66,39 @@ class CardNotificationParserTest { assertThat(p.isRecognized()).isFalse(); } + @Test + @DisplayName("승인 신호가 있어도 명백한 광고어(이벤트/쿠폰/혜택)면 무시") + void blocksAdEvenWithSignal() { + var p = parser.parse("[Web발신]", "삼성카드 승인 9,900원 이벤트 쿠폰 혜택 받으세요", today); + assertThat(p.isRecognized()).isFalse(); + } + + @Test + @DisplayName("실거래(승인)에 포인트/적립이 붙어도 인식 유지") + void keepsRealTxnWithPoints() { + var p = parser.parse("신한카드", "승인 50,000원 스타벅스 포인트 적립 120P", today); + assertThat(p.isRecognized()).isTrue(); + assertThat(p.isIncome()).isFalse(); + assertThat(p.getAmount()).isEqualTo(50000L); + } + + @Test + @DisplayName("'결제'만 있고 약한 광고어(적립/포인트)면 광고로 무시") + void blocksPaymentOnlyAd() { + var p = parser.parse("[Web발신]", "5,000원 결제하고 포인트 적립 받기", today); + assertThat(p.isRecognized()).isFalse(); + } + + @Test + @DisplayName("'결제'만 있어도 광고어 없으면 지출로 인식") + void keepsPaymentOnlyWhenNoAd() { + var p = parser.parse("롯데카드", "결제 3,000원 편의점", today); + assertThat(p.isRecognized()).isTrue(); + assertThat(p.isIncome()).isFalse(); + assertThat(p.isBankTransfer()).isFalse(); + assertThat(p.getAmount()).isEqualTo(3000L); + } + @Test @DisplayName("금액 없으면 무시") void requiresAmount() {