From a1697a7a22fe30577d63201ec99584149fbf6c9e Mon Sep 17 00:00:00 2001 From: ByungCheol Date: Sat, 27 Jun 2026 14:47:00 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9E=90=EC=A3=BC=20=EC=93=B0=EB=8A=94?= =?UTF-8?q?=20=EB=82=B4=EC=97=AD(=EB=B9=A0=EB=A5=B8=EB=93=B1=EB=A1=9D)=20+?= =?UTF-8?q?=20=EB=AC=B8=EC=9E=90/=ED=91=B8=EC=8B=9C=20=ED=85=8D=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=ED=8C=8C=EC=8B=B1=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - quick_entry 테이블 + 도메인/매퍼/서비스/컨트롤러 (/account/quick-entries CRUD) - /account/entries/parse: 문자·푸시 텍스트를 파서로 분석해 결과만 반환(저장X) → iPhone 등 알림 자동수집 불가 시 복사→붙여넣기 자동채움용 Co-Authored-By: Claude Opus 4.8 --- .../account/controller/AccountController.java | 9 +++ .../controller/QuickEntryController.java | 46 +++++++++++++++ .../com/sb/web/account/domain/QuickEntry.java | 31 ++++++++++ .../web/account/dto/ParsedEntryResponse.java | 20 +++++++ .../sb/web/account/dto/QuickEntryRequest.java | 32 +++++++++++ .../web/account/dto/QuickEntryResponse.java | 30 ++++++++++ .../web/account/mapper/QuickEntryMapper.java | 24 ++++++++ .../web/account/service/AccountService.java | 14 +++++ .../account/service/QuickEntryService.java | 56 +++++++++++++++++++ src/main/resources/db/account.sql | 16 ++++++ .../resources/mapper/QuickEntryMapper.xml | 49 ++++++++++++++++ 11 files changed, 327 insertions(+) create mode 100644 src/main/java/com/sb/web/account/controller/QuickEntryController.java create mode 100644 src/main/java/com/sb/web/account/domain/QuickEntry.java create mode 100644 src/main/java/com/sb/web/account/dto/ParsedEntryResponse.java create mode 100644 src/main/java/com/sb/web/account/dto/QuickEntryRequest.java create mode 100644 src/main/java/com/sb/web/account/dto/QuickEntryResponse.java create mode 100644 src/main/java/com/sb/web/account/mapper/QuickEntryMapper.java create mode 100644 src/main/java/com/sb/web/account/service/QuickEntryService.java create mode 100644 src/main/resources/mapper/QuickEntryMapper.xml diff --git a/src/main/java/com/sb/web/account/controller/AccountController.java b/src/main/java/com/sb/web/account/controller/AccountController.java index ac81001..7893fd5 100644 --- a/src/main/java/com/sb/web/account/controller/AccountController.java +++ b/src/main/java/com/sb/web/account/controller/AccountController.java @@ -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( diff --git a/src/main/java/com/sb/web/account/controller/QuickEntryController.java b/src/main/java/com/sb/web/account/controller/QuickEntryController.java new file mode 100644 index 0000000..2af060d --- /dev/null +++ b/src/main/java/com/sb/web/account/controller/QuickEntryController.java @@ -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 list( + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + return service.list(current.getId()); + } + + @PostMapping + public ResponseEntity 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 delete( + @PathVariable Long id, + @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { + service.delete(id, current.getId()); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/sb/web/account/domain/QuickEntry.java b/src/main/java/com/sb/web/account/domain/QuickEntry.java new file mode 100644 index 0000000..4bf431a --- /dev/null +++ b/src/main/java/com/sb/web/account/domain/QuickEntry.java @@ -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; // 조회 시 조인(표시용) +} diff --git a/src/main/java/com/sb/web/account/dto/ParsedEntryResponse.java b/src/main/java/com/sb/web/account/dto/ParsedEntryResponse.java new file mode 100644 index 0000000..e69d5a5 --- /dev/null +++ b/src/main/java/com/sb/web/account/dto/ParsedEntryResponse.java @@ -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; +} diff --git a/src/main/java/com/sb/web/account/dto/QuickEntryRequest.java b/src/main/java/com/sb/web/account/dto/QuickEntryRequest.java new file mode 100644 index 0000000..beae783 --- /dev/null +++ b/src/main/java/com/sb/web/account/dto/QuickEntryRequest.java @@ -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; +} diff --git a/src/main/java/com/sb/web/account/dto/QuickEntryResponse.java b/src/main/java/com/sb/web/account/dto/QuickEntryResponse.java new file mode 100644 index 0000000..cc231fc --- /dev/null +++ b/src/main/java/com/sb/web/account/dto/QuickEntryResponse.java @@ -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(); + } +} diff --git a/src/main/java/com/sb/web/account/mapper/QuickEntryMapper.java b/src/main/java/com/sb/web/account/mapper/QuickEntryMapper.java new file mode 100644 index 0000000..386b48a --- /dev/null +++ b/src/main/java/com/sb/web/account/mapper/QuickEntryMapper.java @@ -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 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); +} diff --git a/src/main/java/com/sb/web/account/service/AccountService.java b/src/main/java/com/sb/web/account/service/AccountService.java index 68a761f..b6bd92c 100644 --- a/src/main/java/com/sb/web/account/service/AccountService.java +++ b/src/main/java/com/sb/web/account/service/AccountService.java @@ -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 = diff --git a/src/main/java/com/sb/web/account/service/QuickEntryService.java b/src/main/java/com/sb/web/account/service/QuickEntryService.java new file mode 100644 index 0000000..84f56fa --- /dev/null +++ b/src/main/java/com/sb/web/account/service/QuickEntryService.java @@ -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 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(); + } +} diff --git a/src/main/resources/db/account.sql b/src/main/resources/db/account.sql index 9515bbd..9aa7076 100644 --- a/src/main/resources/db/account.sql +++ b/src/main/resources/db/account.sql @@ -114,6 +114,22 @@ CREATE TABLE IF NOT EXISTS account_category ( -- 대/소분류 계층 추가(기존 DB 마이그레이션) — 소분류 이름은 그대로, parent_id 만 부여 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 ( id BIGINT NOT NULL AUTO_INCREMENT, diff --git a/src/main/resources/mapper/QuickEntryMapper.xml b/src/main/resources/mapper/QuickEntryMapper.xml new file mode 100644 index 0000000..aadd7f7 --- /dev/null +++ b/src/main/resources/mapper/QuickEntryMapper.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + 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()) + + + + DELETE FROM quick_entry WHERE id = #{id} AND member_id = #{memberId} + + + + +