feat: 자주 쓰는 내역(빠른등록) + 문자/푸시 텍스트 파싱 API
CI / build (push) Failing after 15m21s

- quick_entry 테이블 + 도메인/매퍼/서비스/컨트롤러 (/account/quick-entries CRUD)
- /account/entries/parse: 문자·푸시 텍스트를 파서로 분석해 결과만 반환(저장X)
  → iPhone 등 알림 자동수집 불가 시 복사→붙여넣기 자동채움용

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-06-27 14:47:00 +09:00
parent 63b3ffea24
commit a1697a7a22
11 changed files with 327 additions and 0 deletions
@@ -3,6 +3,7 @@ package com.sb.web.account.controller;
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.ParsedEntryResponse;
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;
@@ -241,6 +242,14 @@ public class AccountController {
return ResponseEntity.status(HttpStatus.CREATED).body(created); return ResponseEntity.status(HttpStatus.CREATED).body(created);
} }
/** 문자/푸시 텍스트 파싱(저장 안 함) → 추가 폼 자동채움. */
@PostMapping("/entries/parse")
public ParsedEntryResponse parseText(
@RequestBody NotificationRequest req,
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
return accountService.parseText(req);
}
/** 미확인 내역 확정 (분류/계좌 보정) */ /** 미확인 내역 확정 (분류/계좌 보정) */
@PostMapping("/entries/{id}/confirm") @PostMapping("/entries/{id}/confirm")
public AccountEntryResponse confirm( public AccountEntryResponse confirm(
@@ -0,0 +1,46 @@
package com.sb.web.account.controller;
import com.sb.web.account.dto.QuickEntryRequest;
import com.sb.web.account.dto.QuickEntryResponse;
import com.sb.web.account.service.QuickEntryService;
import com.sb.web.auth.dto.SessionUser;
import com.sb.web.auth.web.AuthInterceptor;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 자주 쓰는 내역(빠른 등록 템플릿) API. (/api/account/quick-entries — 로그인 필요, 본인 데이터만)
*/
@RestController
@RequestMapping("/api/account/quick-entries")
@RequiredArgsConstructor
public class QuickEntryController {
private final QuickEntryService service;
@GetMapping
public List<QuickEntryResponse> list(
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
return service.list(current.getId());
}
@PostMapping
public ResponseEntity<QuickEntryResponse> create(
@Valid @RequestBody QuickEntryRequest req,
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
return ResponseEntity.status(HttpStatus.CREATED).body(service.create(req, current.getId()));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(
@PathVariable Long id,
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
service.delete(id, current.getId());
return ResponseEntity.noContent().build();
}
}
@@ -0,0 +1,31 @@
package com.sb.web.account.domain;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 자주 쓰는 내역(빠른 등록 템플릿) — 사용자별.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class QuickEntry implements Serializable {
private Long id;
private Long memberId;
private String label;
private String type; // INCOME / EXPENSE
private String category;
private Long amount;
private String memo;
private Long walletId;
private Integer sortOrder;
private LocalDateTime createdAt;
private String walletName; // 조회 시 조인(표시용)
}
@@ -0,0 +1,20 @@
package com.sb.web.account.dto;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDate;
/**
* 문자/푸시 텍스트 파싱 결과 (내역 추가 폼 자동 채움용). 저장하지 않음.
*/
@Data
@Builder
public class ParsedEntryResponse {
private boolean recognized; // 거래로 인식되었는가
private String type; // INCOME / EXPENSE
private long amount;
private String merchant; // 가맹점/적요(메모 후보)
private LocalDate date;
}
@@ -0,0 +1,32 @@
package com.sb.web.account.dto;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import lombok.Data;
/**
* 자주 쓰는 내역 생성 요청.
*/
@Data
public class QuickEntryRequest {
@Size(max = 50, message = "이름은 50자 이내여야 합니다.")
private String label;
@Pattern(regexp = "INCOME|EXPENSE", message = "구분이 올바르지 않습니다.")
private String type;
@Size(max = 50)
private String category;
@NotNull(message = "금액을 입력하세요.")
@Positive(message = "금액은 0보다 커야 합니다.")
private Long amount;
@Size(max = 255)
private String memo;
private Long walletId;
}
@@ -0,0 +1,30 @@
package com.sb.web.account.dto;
import com.sb.web.account.domain.QuickEntry;
import lombok.Builder;
import lombok.Data;
/**
* 자주 쓰는 내역 응답.
*/
@Data
@Builder
public class QuickEntryResponse {
private Long id;
private String label;
private String type;
private String category;
private Long amount;
private String memo;
private Long walletId;
private String walletName;
public static QuickEntryResponse from(QuickEntry q) {
return QuickEntryResponse.builder()
.id(q.getId()).label(q.getLabel()).type(q.getType()).category(q.getCategory())
.amount(q.getAmount()).memo(q.getMemo()).walletId(q.getWalletId())
.walletName(q.getWalletName())
.build();
}
}
@@ -0,0 +1,24 @@
package com.sb.web.account.mapper;
import com.sb.web.account.domain.QuickEntry;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 자주 쓰는 내역 매퍼 — 소유자(member_id) 격리.
*/
@Mapper
public interface QuickEntryMapper {
List<QuickEntry> findByMember(@Param("memberId") Long memberId);
QuickEntry findByIdAndMember(@Param("id") Long id, @Param("memberId") Long memberId);
int insert(QuickEntry e);
int delete(@Param("id") Long id, @Param("memberId") Long memberId);
Integer maxSortOrder(@Param("memberId") Long memberId);
}
@@ -8,6 +8,7 @@ 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.ConfirmRequest;
import com.sb.web.account.dto.NotificationRequest; import com.sb.web.account.dto.NotificationRequest;
import com.sb.web.account.dto.ParsedEntryResponse;
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;
@@ -277,6 +278,19 @@ public class AccountService {
* 카드 결제 푸시 알림 → 미확인(pending) 지출 내역 생성. * 카드 결제 푸시 알림 → 미확인(pending) 지출 내역 생성.
* 결제 알림이 아니거나 중복이면 null 반환(생성 안 함). 취소 알림도 일단 무시(v1). * 결제 알림이 아니거나 중복이면 null 반환(생성 안 함). 취소 알림도 일단 무시(v1).
*/ */
/** 문자/푸시 텍스트만 파싱해 폼 자동채움용 결과 반환 (저장 안 함). iPhone 등 알림 자동수집 불가 대비. */
public ParsedEntryResponse parseText(NotificationRequest req) {
CardNotificationParser.Parsed p =
notificationParser.parse(req.getTitle(), req.getText(), java.time.LocalDate.now());
return ParsedEntryResponse.builder()
.recognized(p.isRecognized())
.type(p.isIncome() ? "INCOME" : "EXPENSE")
.amount(p.getAmount())
.merchant(p.getMerchant())
.date(p.getDate())
.build();
}
@Transactional @Transactional
public AccountEntryResponse createFromNotification(NotificationRequest req, Long memberId) { public AccountEntryResponse createFromNotification(NotificationRequest req, Long memberId) {
CardNotificationParser.Parsed p = CardNotificationParser.Parsed p =
@@ -0,0 +1,56 @@
package com.sb.web.account.service;
import com.sb.web.account.domain.QuickEntry;
import com.sb.web.account.dto.QuickEntryRequest;
import com.sb.web.account.dto.QuickEntryResponse;
import com.sb.web.account.mapper.QuickEntryMapper;
import com.sb.web.common.exception.ApiException;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 자주 쓰는 내역(빠른 등록 템플릿) 서비스 — 사용자별.
*/
@Service
@RequiredArgsConstructor
public class QuickEntryService {
private final QuickEntryMapper mapper;
public List<QuickEntryResponse> list(Long memberId) {
return mapper.findByMember(memberId).stream().map(QuickEntryResponse::from).toList();
}
@Transactional
public QuickEntryResponse create(QuickEntryRequest req, Long memberId) {
Integer max = mapper.maxSortOrder(memberId);
QuickEntry e = QuickEntry.builder()
.memberId(memberId)
.label(blankToNull(req.getLabel()))
.type(req.getType())
.category("EXPENSE".equals(req.getType()) || "INCOME".equals(req.getType()) ? blankToNull(req.getCategory()) : null)
.amount(req.getAmount())
.memo(blankToNull(req.getMemo()))
.walletId(req.getWalletId())
.sortOrder(max == null ? 0 : max + 1)
.build();
mapper.insert(e);
return QuickEntryResponse.from(mapper.findByIdAndMember(e.getId(), memberId));
}
@Transactional
public void delete(Long id, Long memberId) {
if (mapper.findByIdAndMember(id, memberId) == null) {
throw new ApiException(HttpStatus.NOT_FOUND, "자주 쓰는 내역을 찾을 수 없습니다.");
}
mapper.delete(id, memberId);
}
private String blankToNull(String s) {
return (s == null || s.isBlank()) ? null : s.trim();
}
}
+16
View File
@@ -114,6 +114,22 @@ CREATE TABLE IF NOT EXISTS account_category (
-- 대/소분류 계층 추가(기존 DB 마이그레이션) — 소분류 이름은 그대로, parent_id 만 부여 -- 대/소분류 계층 추가(기존 DB 마이그레이션) — 소분류 이름은 그대로, parent_id 만 부여
ALTER TABLE account_category ADD COLUMN IF NOT EXISTS parent_id BIGINT NULL COMMENT '대분류 id. NULL=대분류, 값=소분류'; ALTER TABLE account_category ADD COLUMN IF NOT EXISTS parent_id BIGINT NULL COMMENT '대분류 id. NULL=대분류, 값=소분류';
-- 자주 쓰는 내역(빠른 등록 템플릿) — 사용자별. 칩 탭 시 오늘 날짜로 즉시 등록.
CREATE TABLE IF NOT EXISTS quick_entry (
id BIGINT NOT NULL AUTO_INCREMENT,
member_id BIGINT NOT NULL COMMENT '소유자 member.id',
label VARCHAR(50) NULL COMMENT '칩 표시 이름(없으면 분류/메모 사용)',
type VARCHAR(10) NOT NULL COMMENT 'INCOME / EXPENSE',
category VARCHAR(50) NULL,
amount BIGINT NOT NULL,
memo VARCHAR(255) NULL,
wallet_id BIGINT NULL COMMENT '출금/입금 계좌(account_wallet 등) id',
sort_order INT NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_quick_entry_member (member_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 기본(디폴트) 분류 — 전체 사용자 공용 템플릿(관리자 관리). 사용자가 '기본 분류 불러오기'로 자기 분류에 복사. -- 기본(디폴트) 분류 — 전체 사용자 공용 템플릿(관리자 관리). 사용자가 '기본 분류 불러오기'로 자기 분류에 복사.
CREATE TABLE IF NOT EXISTS default_category ( CREATE TABLE IF NOT EXISTS default_category (
id BIGINT NOT NULL AUTO_INCREMENT, id BIGINT NOT NULL AUTO_INCREMENT,
@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.sb.web.account.mapper.QuickEntryMapper">
<resultMap id="quickResultMap" type="com.sb.web.account.domain.QuickEntry">
<id property="id" column="id"/>
<result property="memberId" column="member_id"/>
<result property="label" column="label"/>
<result property="type" column="type"/>
<result property="category" column="category"/>
<result property="amount" column="amount"/>
<result property="memo" column="memo"/>
<result property="walletId" column="wallet_id"/>
<result property="sortOrder" column="sort_order"/>
<result property="createdAt" column="created_at"/>
<result property="walletName" column="wallet_name"/>
</resultMap>
<select id="findByMember" parameterType="long" resultMap="quickResultMap">
SELECT q.id, q.member_id, q.label, q.type, q.category, q.amount, q.memo,
q.wallet_id, q.sort_order, q.created_at, w.name AS wallet_name
FROM quick_entry q
LEFT JOIN wallet w ON w.id = q.wallet_id AND w.member_id = q.member_id
WHERE q.member_id = #{memberId}
ORDER BY q.sort_order, q.id
</select>
<select id="findByIdAndMember" resultMap="quickResultMap">
SELECT id, member_id, label, type, category, amount, memo, wallet_id, sort_order, created_at
FROM quick_entry
WHERE id = #{id} AND member_id = #{memberId}
</select>
<insert id="insert" parameterType="com.sb.web.account.domain.QuickEntry"
useGeneratedKeys="true" keyProperty="id">
INSERT INTO quick_entry (member_id, label, type, category, amount, memo, wallet_id, sort_order, created_at)
VALUES (#{memberId}, #{label}, #{type}, #{category}, #{amount}, #{memo}, #{walletId}, #{sortOrder}, NOW())
</insert>
<delete id="delete">
DELETE FROM quick_entry WHERE id = #{id} AND member_id = #{memberId}
</delete>
<select id="maxSortOrder" resultType="java.lang.Integer">
SELECT MAX(sort_order) FROM quick_entry WHERE member_id = #{memberId}
</select>
</mapper>