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.AccountEntryResponse;
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.AccountTagResponse;
import com.sb.web.account.dto.CategoryStat;
@@ -241,6 +242,14 @@ public class AccountController {
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")
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.ConfirmRequest;
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.AccountTagResponse;
import com.sb.web.account.dto.CategoryStat;
@@ -277,6 +278,19 @@ public class AccountService {
* 카드 결제 푸시 알림 → 미확인(pending) 지출 내역 생성.
* 결제 알림이 아니거나 중복이면 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
public AccountEntryResponse createFromNotification(NotificationRequest req, Long memberId) {
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();
}
}