feat: 기본(디폴트) 분류 — 관리자 설정 + 사용자 불러오기
CI / build (push) Failing after 13m58s

- default_category 테이블(전체 공용) + 도메인/매퍼/서비스
- 관리자 API /api/admin/default-categories (CRUD·reorder, ADMIN 보호)
- 사용자 /account/categories/import: 기존 내역 기반 → 기본분류 복사로 변경
  (대/소분류 계층 유지, 같은 이름은 건너뜀; CategoryMapper.findByMemberTypeName 추가)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ByungCheol
2026-06-24 23:03:38 +09:00
parent 5f21cc4474
commit 63b3ffea24
10 changed files with 350 additions and 10 deletions
@@ -61,10 +61,10 @@ public class CategoryController {
return categoryService.reorder(current.getId(), req.getType(), req.getIds());
}
/** 기존 내역의 분류를 목록으로 가져오기 */
/** 관리자가 정의한 기본 분류를 내 분류로 불러오기 */
@PostMapping("/import")
public List<CategoryResponse> importFromEntries(
public List<CategoryResponse> importDefaults(
@RequestAttribute(AuthInterceptor.CURRENT_MEMBER) SessionUser current) {
return categoryService.importFromEntries(current.getId());
return categoryService.importDefaults(current.getId());
}
}
@@ -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;
}
@@ -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);
@@ -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<DefaultCategory> 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);
}
@@ -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<CategoryResponse> 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<CategoryResponse> 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<CategoryResponse> importDefaults(Long memberId) {
List<DefaultCategory> defaults = defaultCategoryMapper.findAll();
// 1) 대분류 먼저 생성/확보 → default.id → 내 분류.id 매핑
Map<Long, Long> 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) {
@@ -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<CategoryResponse> 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<CategoryResponse> 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();
}
}
@@ -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<CategoryResponse> list() {
return service.list();
}
@PostMapping
public ResponseEntity<CategoryResponse> 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<Void> delete(@PathVariable Long id) {
service.delete(id);
return ResponseEntity.noContent().build();
}
@PutMapping("/reorder")
public List<CategoryResponse> reorder(@RequestBody CategoryReorderRequest req) {
return service.reorder(req);
}
}
+12
View File
@@ -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 (
@@ -31,6 +31,13 @@
WHERE member_id = #{memberId} AND parent_id = #{parentId}
</select>
<select id="findByMemberTypeName" resultMap="categoryResultMap">
SELECT id, member_id, type, name, parent_id, sort_order, created_at
FROM account_category
WHERE member_id = #{memberId} AND type = #{type} AND name = #{name}
LIMIT 1
</select>
<select id="countByName" resultType="int">
SELECT COUNT(*) FROM account_category
WHERE member_id = #{memberId} AND type = #{type} AND name = #{name}
@@ -0,0 +1,60 @@
<?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.DefaultCategoryMapper">
<resultMap id="defaultCategoryResultMap" type="com.sb.web.account.domain.DefaultCategory">
<id property="id" column="id"/>
<result property="type" column="type"/>
<result property="name" column="name"/>
<result property="parentId" column="parent_id"/>
<result property="sortOrder" column="sort_order"/>
<result property="createdAt" column="created_at"/>
</resultMap>
<select id="findAll" resultMap="defaultCategoryResultMap">
SELECT id, type, name, parent_id, sort_order, created_at
FROM default_category
ORDER BY type, sort_order, name
</select>
<select id="findById" resultMap="defaultCategoryResultMap">
SELECT id, type, name, parent_id, sort_order, created_at
FROM default_category
WHERE id = #{id}
</select>
<select id="countChildren" resultType="int">
SELECT COUNT(*) FROM default_category WHERE parent_id = #{parentId}
</select>
<select id="countByName" resultType="int">
SELECT COUNT(*) FROM default_category
WHERE type = #{type} AND name = #{name}
<if test="excludeId != null">AND id != #{excludeId}</if>
</select>
<insert id="insert" parameterType="com.sb.web.account.domain.DefaultCategory"
useGeneratedKeys="true" keyProperty="id">
INSERT INTO default_category (type, name, parent_id, sort_order, created_at)
VALUES (#{type}, #{name}, #{parentId}, #{sortOrder}, NOW())
</insert>
<update id="update" parameterType="com.sb.web.account.domain.DefaultCategory">
UPDATE default_category SET name = #{name}, parent_id = #{parentId}
WHERE id = #{id}
</update>
<delete id="delete">
DELETE FROM default_category WHERE id = #{id}
</delete>
<update id="updateSortOrder">
UPDATE default_category SET sort_order = #{sortOrder} WHERE id = #{id}
</update>
<select id="maxSortOrder" resultType="java.lang.Integer">
SELECT MAX(sort_order) FROM default_category WHERE type = #{type}
</select>
</mapper>