c05be0880c
- 관리자 회원가입 허용 토글(app_setting), 공개 GET /auth/signup-enabled - 회원가입 봇차단: 허니팟(website) + IP 레이트리밋(Redis, 1h 5회) - 카드 알림: 현금 오선택 보정(카드 양방향 매칭+단일카드 자동), 광고 푸시 차단(승인신호 없는 광고성 표현 무시) - @MapperScan 에 admin.mapper 추가 - account.sql: 매 기동 wallet MODIFY 제거(라이브 락 위험) — CREATE 정의에 255 반영됨 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
140 lines
6.1 KiB
Java
140 lines
6.1 KiB
Java
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})");
|
|
|
|
// 광고/이벤트 푸시에 흔한 표현 — 강한 승인신호(승인/출금)가 없으면 결제로 보지 않는다.
|
|
private static final Pattern AD_HINT = Pattern.compile(
|
|
"이벤트|광고|혜택|쿠폰|할인|적립|포인트|캐시백|마일리지|리워드|당첨|응모|추첨|프로모션|배너|무료|증정|모집");
|
|
|
|
@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();
|
|
}
|
|
// 광고/이벤트 차단: 강한 승인신호(승인/출금/취소/환불)가 없는데 광고성 표현이 있으면 결제 아님
|
|
boolean strongSignal = raw.contains("승인") || raw.contains("출금") || canceled;
|
|
if (!strongSignal && AD_HINT.matcher(raw).find()) {
|
|
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;
|
|
}
|
|
}
|