From 63b3ffea24030bc81d93fbda71ca8617b5025c55 Mon Sep 17 00:00:00 2001 From: ByungCheol Date: Wed, 24 Jun 2026 23:03:38 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EA=B8=B0=EB=B3=B8(=EB=94=94=ED=8F=B4?= =?UTF-8?q?=ED=8A=B8)=20=EB=B6=84=EB=A5=98=20=E2=80=94=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=20=EC=84=A4=EC=A0=95=20+=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - default_category 테이블(전체 공용) + 도메인/매퍼/서비스 - 관리자 API /api/admin/default-categories (CRUD·reorder, ADMIN 보호) - 사용자 /account/categories/import: 기존 내역 기반 → 기본분류 복사로 변경 (대/소분류 계층 유지, 같은 이름은 건너뜀; CategoryMapper.findByMemberTypeName 추가) Co-Authored-By: Claude Opus 4.8 --- .../controller/CategoryController.java | 6 +- .../web/account/domain/DefaultCategory.java | 26 ++++ .../sb/web/account/mapper/CategoryMapper.java | 5 + .../account/mapper/DefaultCategoryMapper.java | 32 +++++ .../web/account/service/CategoryService.java | 43 +++++-- .../service/DefaultCategoryService.java | 114 ++++++++++++++++++ .../controller/AdminCategoryController.java | 55 +++++++++ src/main/resources/db/account.sql | 12 ++ src/main/resources/mapper/CategoryMapper.xml | 7 ++ .../mapper/DefaultCategoryMapper.xml | 60 +++++++++ 10 files changed, 350 insertions(+), 10 deletions(-) create mode 100644 src/main/java/com/sb/web/account/domain/DefaultCategory.java create mode 100644 src/main/java/com/sb/web/account/mapper/DefaultCategoryMapper.java create mode 100644 src/main/java/com/sb/web/account/service/DefaultCategoryService.java create mode 100644 src/main/java/com/sb/web/admin/controller/AdminCategoryController.java create mode 100644 src/main/resources/mapper/DefaultCategoryMapper.xml diff --git a/src/main/java/com/sb/web/account/controller/CategoryController.java b/src/main/java/com/sb/web/account/controller/CategoryController.java index ded8db1..abc85ac 100644 --- a/src/main/java/com/sb/web/account/controller/CategoryController.java +++ b/src/main/java/com/sb/web/account/controller/CategoryController.java @@ -61,10 +61,10 @@ public class CategoryController { return categoryService.reorder(current.getId(), req.getType(), req.getIds()); } - /** 기존 내역의 분류를 목록으로 가져오기 */ + /** 관리자가 정의한 기본 분류를 내 분류로 불러오기 */ @PostMapping("/import") - public List importFromEntries( + public List importDefaults( @RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) { - return categoryService.importFromEntries(current.getId()); + return categoryService.importDefaults(current.getId()); } } diff --git a/src/main/java/com/sb/web/account/domain/DefaultCategory.java b/src/main/java/com/sb/web/account/domain/DefaultCategory.java new file mode 100644 index 0000000..e192c60 --- /dev/null +++ b/src/main/java/com/sb/web/account/domain/DefaultCategory.java @@ -0,0 +1,26 @@ +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 DefaultCategory implements Serializable { + + private Long id; + private String type; // INCOME / EXPENSE + private String name; + private Long parentId; // 대분류 id (NULL=대분류, 값=소분류) + private Integer sortOrder; + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/sb/web/account/mapper/CategoryMapper.java b/src/main/java/com/sb/web/account/mapper/CategoryMapper.java index c15e2be..de40137 100644 --- a/src/main/java/com/sb/web/account/mapper/CategoryMapper.java +++ b/src/main/java/com/sb/web/account/mapper/CategoryMapper.java @@ -16,6 +16,11 @@ public interface CategoryMapper { AccountCategory findByIdAndMember(@Param("id") Long id, @Param("memberId") Long memberId); + /** 이름으로 조회 (기본분류 불러오기 중복 확인용) */ + AccountCategory findByMemberTypeName(@Param("memberId") Long memberId, + @Param("type") String type, + @Param("name") String name); + /** 해당 대분류(parent_id)에 속한 소분류 개수 */ int countChildren(@Param("memberId") Long memberId, @Param("parentId") Long parentId); diff --git a/src/main/java/com/sb/web/account/mapper/DefaultCategoryMapper.java b/src/main/java/com/sb/web/account/mapper/DefaultCategoryMapper.java new file mode 100644 index 0000000..5db3c75 --- /dev/null +++ b/src/main/java/com/sb/web/account/mapper/DefaultCategoryMapper.java @@ -0,0 +1,32 @@ +package com.sb.web.account.mapper; + +import com.sb.web.account.domain.DefaultCategory; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 기본(디폴트) 분류 매퍼 — 전체 공용(소유자 격리 없음). + */ +@Mapper +public interface DefaultCategoryMapper { + + List findAll(); + + DefaultCategory findById(@Param("id") Long id); + + int countChildren(@Param("parentId") Long parentId); + + int countByName(@Param("type") String type, @Param("name") String name, @Param("excludeId") Long excludeId); + + int insert(DefaultCategory category); + + int update(DefaultCategory category); + + int delete(@Param("id") Long id); + + int updateSortOrder(@Param("id") Long id, @Param("sortOrder") int sortOrder); + + Integer maxSortOrder(@Param("type") String type); +} diff --git a/src/main/java/com/sb/web/account/service/CategoryService.java b/src/main/java/com/sb/web/account/service/CategoryService.java index 69e53a9..465d89f 100644 --- a/src/main/java/com/sb/web/account/service/CategoryService.java +++ b/src/main/java/com/sb/web/account/service/CategoryService.java @@ -1,21 +1,25 @@ package com.sb.web.account.service; import com.sb.web.account.domain.AccountCategory; +import com.sb.web.account.domain.DefaultCategory; import com.sb.web.account.dto.CategoryRequest; import com.sb.web.account.dto.CategoryResponse; import com.sb.web.account.mapper.AccountEntryMapper; import com.sb.web.account.mapper.BudgetMapper; import com.sb.web.account.mapper.CategoryMapper; +import com.sb.web.account.mapper.DefaultCategoryMapper; 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.HashMap; import java.util.List; +import java.util.Map; /** - * 분류(카테고리) 서비스. 사용자별 CRUD, 이름 변경 시 내역·예산 전파, 기존 분류 불러오기. + * 분류(카테고리) 서비스. 사용자별 CRUD, 이름 변경 시 내역·예산 전파, 기본분류 불러오기. */ @Service @RequiredArgsConstructor @@ -24,6 +28,7 @@ public class CategoryService { private final CategoryMapper categoryMapper; private final AccountEntryMapper entryMapper; private final BudgetMapper budgetMapper; + private final DefaultCategoryMapper defaultCategoryMapper; public List list(Long memberId) { return categoryMapper.findByMember(memberId).stream().map(CategoryResponse::from).toList(); @@ -114,18 +119,42 @@ public class CategoryService { categoryMapper.delete(id, memberId); } - /** 기존 내역에 쓰인 분류명을 분류 목록으로 가져오기 (중복 무시) */ + /** 관리자가 정의한 기본(디폴트) 분류를 내 분류로 복사 (대/소분류 계층 유지, 같은 이름은 건너뜀) */ @Transactional - public List importFromEntries(Long memberId) { - for (String type : new String[]{"INCOME", "EXPENSE"}) { - for (String name : entryMapper.findDistinctCategories(memberId, type)) { - categoryMapper.insertIgnore(AccountCategory.builder() - .memberId(memberId).type(type).name(name).sortOrder(0).build()); + public List importDefaults(Long memberId) { + List defaults = defaultCategoryMapper.findAll(); + // 1) 대분류 먼저 생성/확보 → default.id → 내 분류.id 매핑 + Map majorMap = new HashMap<>(); + for (DefaultCategory d : defaults) { + if (d.getParentId() != null) { + continue; } + majorMap.put(d.getId(), ensureCategory(memberId, d.getType(), d.getName(), null)); + } + // 2) 소분류 생성 (매핑된 대분류 아래; 부모 매핑이 없으면 대분류로 취급) + for (DefaultCategory d : defaults) { + if (d.getParentId() == null) { + continue; + } + ensureCategory(memberId, d.getType(), d.getName(), majorMap.get(d.getParentId())); } return list(memberId); } + /** 같은 이름 분류가 있으면 그 id, 없으면 생성해서 id 반환 */ + private Long ensureCategory(Long memberId, String type, String name, Long parentId) { + AccountCategory existing = categoryMapper.findByMemberTypeName(memberId, type, name); + if (existing != null) { + return existing.getId(); + } + Integer max = categoryMapper.maxSortOrder(memberId, type); + AccountCategory c = AccountCategory.builder() + .memberId(memberId).type(type).name(name).parentId(parentId) + .sortOrder(max == null ? 0 : max + 1).build(); + categoryMapper.insert(c); + return c.getId(); + } + private AccountCategory mustFind(Long id, Long memberId) { AccountCategory c = categoryMapper.findByIdAndMember(id, memberId); if (c == null) { diff --git a/src/main/java/com/sb/web/account/service/DefaultCategoryService.java b/src/main/java/com/sb/web/account/service/DefaultCategoryService.java new file mode 100644 index 0000000..1bb7fde --- /dev/null +++ b/src/main/java/com/sb/web/account/service/DefaultCategoryService.java @@ -0,0 +1,114 @@ +package com.sb.web.account.service; + +import com.sb.web.account.domain.DefaultCategory; +import com.sb.web.account.dto.CategoryReorderRequest; +import com.sb.web.account.dto.CategoryRequest; +import com.sb.web.account.dto.CategoryResponse; +import com.sb.web.account.mapper.DefaultCategoryMapper; +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; + +/** + * 기본(디폴트) 분류 서비스 — 전체 공용 템플릿(관리자 전용 CRUD). 2단계(대/소분류) 제한. + */ +@Service +@RequiredArgsConstructor +public class DefaultCategoryService { + + private final DefaultCategoryMapper mapper; + + public List list() { + return mapper.findAll().stream().map(this::toResponse).toList(); + } + + @Transactional + public CategoryResponse create(CategoryRequest req) { + String name = req.getName().trim(); + if (mapper.countByName(req.getType(), name, null) > 0) { + throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 분류입니다."); + } + validateParent(req.getParentId(), req.getType()); + Integer max = mapper.maxSortOrder(req.getType()); + DefaultCategory c = DefaultCategory.builder() + .type(req.getType()).name(name).parentId(req.getParentId()) + .sortOrder(max == null ? 0 : max + 1).build(); + mapper.insert(c); + return toResponse(c); + } + + @Transactional + public CategoryResponse update(Long id, CategoryRequest req) { + DefaultCategory c = mustFind(id); + String newName = req.getName().trim(); + if (mapper.countByName(c.getType(), newName, id) > 0) { + throw new ApiException(HttpStatus.CONFLICT, "이미 존재하는 분류입니다."); + } + Long newParent = req.getParentId(); + if (newParent != null) { + if (newParent.equals(id)) { + throw new ApiException(HttpStatus.BAD_REQUEST, "자기 자신을 대분류로 지정할 수 없습니다."); + } + if (mapper.countChildren(id) > 0) { + throw new ApiException(HttpStatus.BAD_REQUEST, "소분류가 있는 대분류는 소분류로 바꿀 수 없습니다."); + } + } + validateParent(newParent, c.getType()); + c.setName(newName); + c.setParentId(newParent); + mapper.update(c); + return toResponse(c); + } + + @Transactional + public void delete(Long id) { + mustFind(id); + if (mapper.countChildren(id) > 0) { + throw new ApiException(HttpStatus.BAD_REQUEST, "소분류가 있는 대분류는 삭제할 수 없습니다. 소분류를 먼저 옮기거나 삭제하세요."); + } + mapper.delete(id); + } + + @Transactional + public List reorder(CategoryReorderRequest req) { + int i = 0; + for (Long id : req.getIds()) { + DefaultCategory c = mapper.findById(id); + if (c != null && c.getType().equals(req.getType())) { + mapper.updateSortOrder(id, i++); + } + } + return list(); + } + + private void validateParent(Long parentId, String type) { + if (parentId == null) { + return; + } + DefaultCategory parent = mapper.findById(parentId); + if (parent == null || !parent.getType().equals(type)) { + throw new ApiException(HttpStatus.BAD_REQUEST, "대분류가 올바르지 않습니다."); + } + if (parent.getParentId() != null) { + throw new ApiException(HttpStatus.BAD_REQUEST, "대분류 아래에만 소분류를 둘 수 있습니다(2단계)."); + } + } + + private DefaultCategory mustFind(Long id) { + DefaultCategory c = mapper.findById(id); + if (c == null) { + throw new ApiException(HttpStatus.NOT_FOUND, "기본 분류를 찾을 수 없습니다."); + } + return c; + } + + private CategoryResponse toResponse(DefaultCategory c) { + return CategoryResponse.builder() + .id(c.getId()).type(c.getType()).name(c.getName()).parentId(c.getParentId()) + .build(); + } +} diff --git a/src/main/java/com/sb/web/admin/controller/AdminCategoryController.java b/src/main/java/com/sb/web/admin/controller/AdminCategoryController.java new file mode 100644 index 0000000..555ece2 --- /dev/null +++ b/src/main/java/com/sb/web/admin/controller/AdminCategoryController.java @@ -0,0 +1,55 @@ +package com.sb.web.admin.controller; + +import com.sb.web.account.dto.CategoryReorderRequest; +import com.sb.web.account.dto.CategoryRequest; +import com.sb.web.account.dto.CategoryResponse; +import com.sb.web.account.service.DefaultCategoryService; +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/admin/** — AdminInterceptor 로 ADMIN 권한 필요) + * GET /default-categories 기본 분류 목록 + * POST /default-categories 생성(대/소분류) + * PUT /default-categories/{id} 수정(이름·대분류) + * DELETE /default-categories/{id} 삭제 + * PUT /default-categories/reorder 순서 변경 + */ +@RestController +@RequestMapping("/api/admin/default-categories") +@RequiredArgsConstructor +public class AdminCategoryController { + + private final DefaultCategoryService service; + + @GetMapping + public List list() { + return service.list(); + } + + @PostMapping + public ResponseEntity create(@Valid @RequestBody CategoryRequest req) { + return ResponseEntity.status(HttpStatus.CREATED).body(service.create(req)); + } + + @PutMapping("/{id}") + public CategoryResponse update(@PathVariable Long id, @Valid @RequestBody CategoryRequest req) { + return service.update(id, req); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable Long id) { + service.delete(id); + return ResponseEntity.noContent().build(); + } + + @PutMapping("/reorder") + public List reorder(@RequestBody CategoryReorderRequest req) { + return service.reorder(req); + } +} diff --git a/src/main/resources/db/account.sql b/src/main/resources/db/account.sql index 60e85c5..9515bbd 100644 --- a/src/main/resources/db/account.sql +++ b/src/main/resources/db/account.sql @@ -114,6 +114,18 @@ 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 default_category ( + id BIGINT NOT NULL AUTO_INCREMENT, + type VARCHAR(10) NOT NULL COMMENT 'INCOME / EXPENSE', + name VARCHAR(50) NOT NULL, + parent_id BIGINT NULL COMMENT '대분류 id(default_category.id). NULL=대분류, 값=소분류', + sort_order INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_default_category_type_name (type, name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + -- 예산 (사용자별, 카테고리별) -- fixed=1(고정): base_unit/base_amount 로 일수기준 환산 / fixed=0(비고정): daily/weekly/monthly/yearly 직접 CREATE TABLE IF NOT EXISTS budget ( diff --git a/src/main/resources/mapper/CategoryMapper.xml b/src/main/resources/mapper/CategoryMapper.xml index c242bcf..b6c1085 100644 --- a/src/main/resources/mapper/CategoryMapper.xml +++ b/src/main/resources/mapper/CategoryMapper.xml @@ -31,6 +31,13 @@ WHERE member_id = #{memberId} AND parent_id = #{parentId} + + + SELECT id, type, name, parent_id, sort_order, created_at + FROM default_category + ORDER BY type, sort_order, name + + + + + + + + + + INSERT INTO default_category (type, name, parent_id, sort_order, created_at) + VALUES (#{type}, #{name}, #{parentId}, #{sortOrder}, NOW()) + + + + UPDATE default_category SET name = #{name}, parent_id = #{parentId} + WHERE id = #{id} + + + + DELETE FROM default_category WHERE id = #{id} + + + + UPDATE default_category SET sort_order = #{sortOrder} WHERE id = #{id} + + + + +