Merge branch 'dev'
Deploy / deploy (push) Failing after 11m21s
CI / build (push) Failing after 13m3s

This commit is contained in:
ByungCheol
2026-06-03 18:51:32 +09:00
10 changed files with 314 additions and 3 deletions
@@ -6,6 +6,8 @@ import com.sb.web.account.dto.AccountSummary;
import com.sb.web.account.dto.AccountTagRequest; import com.sb.web.account.dto.AccountTagRequest;
import com.sb.web.account.dto.AccountTagResponse; import com.sb.web.account.dto.AccountTagResponse;
import com.sb.web.account.dto.CategoryStat; 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.NetWorthPoint;
import com.sb.web.account.dto.NetWorthResponse; import com.sb.web.account.dto.NetWorthResponse;
import com.sb.web.account.dto.PeriodStat; import com.sb.web.account.dto.PeriodStat;
@@ -23,6 +25,7 @@ import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
* 가계부 API. (/api/account/** — 로그인 필요, 본인 데이터만 접근) * 가계부 API. (/api/account/** — 로그인 필요, 본인 데이터만 접근)
@@ -202,4 +205,34 @@ public class AccountController {
accountService.delete(id, current.getId()); accountService.delete(id, current.getId());
return ResponseEntity.noContent().build(); 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 Long toWalletId; // 이체 입금 계좌
private String toWalletName; private String toWalletName;
private Integer installmentMonths; // 카드 할부 개월수(2~24, 일시불은 null) private Integer installmentMonths; // 카드 할부 개월수(2~24, 일시불은 null)
private Boolean pending; // 확인 필요(미확정) 여부 — 알림 자동인식 건
private String notifKey; // 알림 자동인식 중복방지 키
private LocalDateTime createdAt; private LocalDateTime createdAt;
private LocalDateTime updatedAt; private LocalDateTime updatedAt;
} }
@@ -25,6 +25,7 @@ public class AccountEntryResponse {
private Long toWalletId; private Long toWalletId;
private String toWalletName; private String toWalletName;
private Integer installmentMonths; private Integer installmentMonths;
private Boolean pending;
private List<String> tags; private List<String> tags;
public static AccountEntryResponse from(AccountEntry e, List<String> tags) { public static AccountEntryResponse from(AccountEntry e, List<String> tags) {
@@ -40,6 +41,7 @@ public class AccountEntryResponse {
.toWalletId(e.getToWalletId()) .toWalletId(e.getToWalletId())
.toWalletName(e.getToWalletName()) .toWalletName(e.getToWalletName())
.installmentMonths(e.getInstallmentMonths()) .installmentMonths(e.getInstallmentMonths())
.pending(Boolean.TRUE.equals(e.getPending()))
.tags(tags) .tags(tags)
.build(); .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); 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 (분류 불러오기용) */ /** 기존 내역의 (type별) 사용된 분류명 distinct (분류 불러오기용) */
List<String> findDistinctCategories(@Param("memberId") Long memberId, @Param("type") String type); 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.AccountEntryRequest;
import com.sb.web.account.dto.AccountEntryResponse; import com.sb.web.account.dto.AccountEntryResponse;
import com.sb.web.account.dto.AccountSummary; 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.AccountTagRequest;
import com.sb.web.account.dto.AccountTagResponse; import com.sb.web.account.dto.AccountTagResponse;
import com.sb.web.account.dto.CategoryStat; import com.sb.web.account.dto.CategoryStat;
@@ -43,6 +45,7 @@ public class AccountService {
private final AccountTagMapper tagMapper; private final AccountTagMapper tagMapper;
private final WalletMapper walletMapper; private final WalletMapper walletMapper;
private final InvestService investService; private final InvestService investService;
private final CardNotificationParser notificationParser;
/* ===================== 항목 ===================== */ /* ===================== 항목 ===================== */
@@ -202,6 +205,73 @@ public class AccountService {
mapper.delete(id, memberId); 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건 생성 */ /** 대출/카드 상환·납부: 원금은 이체(부채↓), 이자는 지출로 분리해 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;
}
}
+6
View File
@@ -40,6 +40,8 @@ CREATE TABLE IF NOT EXISTS account_entry (
wallet_id BIGINT NULL COMMENT '발생/출금 계좌(wallet.id)', wallet_id BIGINT NULL COMMENT '발생/출금 계좌(wallet.id)',
to_wallet_id BIGINT NULL COMMENT '이체 입금 계좌(TRANSFER)', to_wallet_id BIGINT NULL COMMENT '이체 입금 계좌(TRANSFER)',
installment_months INT NULL COMMENT '카드 할부 개월수(2~24, 일시불은 NULL)', 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, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id), 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 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 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 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 ( CREATE TABLE IF NOT EXISTS account_tag (
@@ -16,6 +16,8 @@
<result property="toWalletId" column="to_wallet_id"/> <result property="toWalletId" column="to_wallet_id"/>
<result property="toWalletName" column="to_wallet_name"/> <result property="toWalletName" column="to_wallet_name"/>
<result property="installmentMonths" column="installment_months"/> <result property="installmentMonths" column="installment_months"/>
<result property="pending" column="pending"/>
<result property="notifKey" column="notif_key"/>
<result property="createdAt" column="created_at"/> <result property="createdAt" column="created_at"/>
<result property="updatedAt" column="updated_at"/> <result property="updatedAt" column="updated_at"/>
</resultMap> </resultMap>
@@ -29,7 +31,7 @@
e.id, e.member_id, e.entry_date, e.type, e.category, e.amount, e.memo, 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.wallet_id, w.name AS wallet_name,
e.to_wallet_id, tw.name AS to_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 e.created_at, e.updated_at
</sql> </sql>
<sql id="entryJoins"> <sql id="entryJoins">
@@ -127,9 +129,11 @@
<insert id="insert" parameterType="com.sb.web.account.domain.AccountEntry" <insert id="insert" parameterType="com.sb.web.account.domain.AccountEntry"
useGeneratedKeys="true" keyProperty="id"> useGeneratedKeys="true" keyProperty="id">
INSERT INTO account_entry (member_id, entry_date, type, category, amount, memo, 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}, VALUES (#{memberId}, #{entryDate}, #{type}, #{category}, #{amount}, #{memo},
#{walletId}, #{toWalletId}, #{installmentMonths}, NOW(), NOW()) #{walletId}, #{toWalletId}, #{installmentMonths},
COALESCE(#{pending}, 0), #{notifKey}, NOW(), NOW())
</insert> </insert>
<update id="update" parameterType="com.sb.web.account.domain.AccountEntry"> <update id="update" parameterType="com.sb.web.account.domain.AccountEntry">
@@ -144,6 +148,27 @@
DELETE FROM account_entry WHERE id = #{id} AND member_id = #{memberId} DELETE FROM account_entry WHERE id = #{id} AND member_id = #{memberId}
</delete> </delete>
<!-- ===== 알림 자동인식(미확인) ===== -->
<select id="findByMemberAndNotifKey" resultMap="entryResultMap">
SELECT <include refid="entryCols"/>
<include refid="entryJoins"/>
WHERE e.member_id = #{memberId} AND e.notif_key = #{notifKey}
LIMIT 1
</select>
<select id="countPending" resultType="int">
SELECT COUNT(*) FROM account_entry WHERE member_id = #{memberId} AND pending = 1
</select>
<update id="confirm">
UPDATE account_entry
SET pending = 0,
<if test="category != null">category = #{category},</if>
<if test="walletId != null">wallet_id = #{walletId},</if>
updated_at = NOW()
WHERE id = #{id} AND member_id = #{memberId} AND pending = 1
</update>
<select id="findDistinctCategories" resultType="string"> <select id="findDistinctCategories" resultType="string">
SELECT DISTINCT category FROM account_entry SELECT DISTINCT category FROM account_entry
WHERE member_id = #{memberId} AND type = #{type} WHERE member_id = #{memberId} AND type = #{type}