- 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:
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user