Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ public ApiResponse<TaskOrderCategoryUpdateResponseDTO> updateTaskOrder(

Long userId = Utils.getUserId();
return ApiResponse.onSuccess(
taskCommandService.updateTaskOrders(userId, request),
taskCommandService.updateTaskOrderAndCategory(userId, request),
SuccessStatus.UPDATE_TASK_ORDER_SUCCESS.getCode(),
SuccessStatus.UPDATE_TASK_ORDER_SUCCESS.getMessage());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

import java.time.LocalDate;
import java.util.Collection;
import java.util.List;
import java.util.Optional;

Expand Down Expand Up @@ -53,4 +55,6 @@ List<Task> findAllByUserIdAndCategoryIdAndDueDateOrderByPosition(
);

List<Task> findAllByCategoryId(Long categoryId);

List<Task> findAllByUserIdAndCategoryIdAndDueDate(Long userId, Long categoryId, LocalDate dueDate);
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ public interface TaskCommandService {
* @param userId
* @param request 요청 DTO
*/
TaskOrderCategoryUpdateResponseDTO updateTaskOrders(Long userId, TaskOrderCategoryUpdateBulkRequestDTO request);
TaskOrderCategoryUpdateResponseDTO updateTaskOrderAndCategory(Long userId, TaskOrderCategoryUpdateBulkRequestDTO request);

/**
* 할 일의 체크 상태를 토글합니다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public class TaskCommandServiceImpl implements TaskCommandService {
private final TaskConverter taskConverter;
private final UserRepository userRepository;

// 유니크키(user, category, dueDate, position) 충돌 회피용 임시 position 베이스
// 유니크키(user, category, dueDate, position) 충돌 방지용 임시 position 베이스
private static final int TEMP_BASE = 1_000_000;

// 버킷 키: (categoryId, dueDate) – userId는 메서드 인자로 고정
Expand Down Expand Up @@ -138,10 +138,10 @@ public TaskCheckUpdateResponseDTO toggleCheck(Long userId, Long taskId) {
}

/**
* 카테고리 내 할 일 순서를 변경합니다.
* 카테고리 내 할 일 순서를 변경, 카테고리를 이동합니다.
*/
@Transactional
public TaskOrderCategoryUpdateResponseDTO updateTaskOrders(Long userId, TaskOrderCategoryUpdateBulkRequestDTO request) {
public TaskOrderCategoryUpdateResponseDTO updateTaskOrderAndCategory(Long userId, TaskOrderCategoryUpdateBulkRequestDTO request) {
// 요청 유효성 검사
List<TaskOrderCategoryUpdateRequestDTO> taskRequests = validateTaskOrdersRequest(request);

Expand Down Expand Up @@ -175,7 +175,7 @@ private TaskOrderCategoryUpdateResponseDTO buildReorderResponse(int size, Set<Lo
* - 버킷 단위: (userId, categoryId, dueDate)
* - 슬롯 방식: order를 최종 인덱스(절대 위치)로 해석
* - 부분 요청 허용: 요청에 없는 것은 기존 순서대로 빈 칸 채움
* - 유니크키 회피: 임시 pos → flush → 최종 pos → flush
* - 유니크키 충돌 방지: 임시 pos → flush → 최종 pos → flush
*
* @param userId
* @param requestList
Expand All @@ -189,16 +189,24 @@ private Set<Long> applyCategoryWiseReordering(
) {
Set<Long> affectedCategories = new HashSet<>();

// 0) 모든 요청의 타겟 버킷 맵 (taskId -> (categoryId, dueDate))
Map<Long, BucketKey> targetByTaskId = requestList.stream().collect(Collectors.toMap(
TaskOrderCategoryUpdateRequestDTO::getTaskId,
dto -> {
Task t = taskMap.get(dto.getTaskId());
LocalDate due = t.getDueDate();
return new BucketKey(dto.getCategoryId(), due);
}
));

// 1) 요청을 (categoryId, dueDate) 버킷으로 그룹핑
// * task의 현재 dueDate 사용
Map<BucketKey, List<TaskOrderCategoryUpdateRequestDTO>> groupedRequests =
requestList.stream().collect(Collectors.groupingBy(dto -> {
Task t = taskMap.get(dto.getTaskId());
LocalDate due = t.getDueDate();
return new BucketKey(dto.getCategoryId(), due);
}));

// 2) 각 버킷별로 정렬 수행
for (Map.Entry<BucketKey, List<TaskOrderCategoryUpdateRequestDTO>> entry : groupedRequests.entrySet()) {
BucketKey bucket = entry.getKey();
Long categoryId = bucket.categoryId();
Expand All @@ -208,34 +216,48 @@ private Set<Long> applyCategoryWiseReordering(
Category category = categoryRepository.findById(categoryId)
.orElseThrow(() -> new GeneralException(ErrorStatus.TASK_CATEGORY_NOT_FOUND));

log.debug("[TASK][REORDER] bucket(categoryId={}, dueDate={}) - 요청 task 수: {}",
categoryId, dueDate, requestTasks.size());
log.debug("[TASK][REORDER] bucket(categoryId={}, dueDate={}) - 요청 task 수: {}", categoryId, dueDate, requestTasks.size());

// 2-1) 버킷 전체 조회 (기존 position 오름차순 보장되어야 함)
// 2-1) 현재 버킷의 전체 tasks (현재 카테고리 기준) 조회 + position 오름차순 정렬
List<Task> allTasksInBucket = taskRepository
.findAllByUserIdAndCategoryIdAndDueDateOrderByPosition(userId, categoryId, dueDate);
.findAllByUserIdAndCategoryIdAndDueDate(userId, categoryId, dueDate)
.stream()
.sorted(Comparator.comparingInt(Task::getPosition))
.toList();

log.debug("[TASK][REORDER] bucket(categoryId={}, dueDate={}) - DB 내 전체 task 수: {}", categoryId, dueDate, allTasksInBucket.size());
log.debug("[TASK][REORDER] 기존 position 오름차순 정렬 완료 - 첫 position={}, 마지막 position={}",
allTasksInBucket.isEmpty() ? null : allTasksInBucket.get(0).getPosition(),
allTasksInBucket.isEmpty() ? null : allTasksInBucket.get(allTasksInBucket.size() - 1).getPosition());

// 2-2) base = (staying) U (incoming)
// * staying = 현재 버킷에 있고, 다른 버킷으로 이동하지 않는 항목
// * incoming = 다른 버킷에 있었고, 요청에서 현재로 이동하도록 지정된 항목
Set<Long> requestedIds = requestTasks.stream().map(TaskOrderCategoryUpdateRequestDTO::getTaskId).collect(Collectors.toSet());
List<Task> incoming = requestTasks.stream().map(r -> taskMap.get(r.getTaskId())).toList();
List<Task> staying = allTasksInBucket.stream()
.filter(t -> {
BucketKey target = targetByTaskId.get(t.getId());
// target이 없으면(이번 요청에 없음) 남아있는 것으로 간주
return (target == null) || target.equals(bucket);
})
.toList();

log.debug("[TASK][REORDER] bucket(categoryId={}, dueDate={}) - DB 내 전체 task 수: {}",
categoryId, dueDate, allTasksInBucket.size());
// base = staying ∪ incoming (taskId 기준 distinct)
Map<Long, Task> baseMap = new LinkedHashMap<>();
for (Task t : staying) baseMap.put(t.getId(), t);
for (Task t : incoming) baseMap.put(t.getId(), t);
List<Task> base = new ArrayList<>(baseMap.values());
int n = base.size();

// 2-2) 요청된 task → 절대 인덱스 맵
Map<Long, Integer> requestedOrderMap = requestTasks.stream()
.collect(Collectors.toMap(TaskOrderCategoryUpdateRequestDTO::getTaskId, TaskOrderCategoryUpdateRequestDTO::getOrder));
Set<Long> requestedIds = new HashSet<>(requestedOrderMap.keySet());
log.debug("[TASK][REORDER] base 크기 확정 - staying={}, incoming={}, base={}", staying.size(), incoming.size(), n);

// 2-3) 슬롯 방식: order를 최종 인덱스로 사용
int n = allTasksInBucket.size();
// 2-3) 슬롯 구성: 요청 order 우선 배치, 중복/범위외는 overflow
Task[] slots = new Task[n];
List<Task> overflow = new ArrayList<>();

// 요청된 task들을 order 오름차순으로 정렬
List<Task> requestedTasksForAbsolute = allTasksInBucket.stream()
.filter(t -> requestedIds.contains(t.getId()))
.sorted(Comparator.comparingInt(t -> requestedOrderMap.get(t.getId())))
.toList();

for (Task t : requestedTasksForAbsolute) {
Integer target = requestedOrderMap.get(t.getId());
for (TaskOrderCategoryUpdateRequestDTO r : requestTasks) {
Task t = taskMap.get(r.getTaskId());
Integer target = r.getOrder();
if (target == null || target < 0 || target >= n) {
overflow.add(t); // 범위 밖은 뒤에 채움
continue;
Expand All @@ -247,12 +269,9 @@ private Set<Long> applyCategoryWiseReordering(
}
}

// 2-4) 빈 칸을 untouched(요청 미포함) → overflow 순으로 채움
Iterator<Task> untouchedIt = allTasksInBucket.stream()
.filter(t -> !requestedIds.contains(t.getId()))
.iterator();
// 2-4) 빈 칸을 untouched(= base 중 요청에 포함되지 않은 것) → overflow 순으로 채움
Iterator<Task> untouchedIt = base.stream().filter(t -> !requestedIds.contains(t.getId())).iterator();
Iterator<Task> overflowIt = overflow.iterator();

for (int i = 0; i < n; i++) {
if (slots[i] == null) {
if (untouchedIt.hasNext()) {
Expand All @@ -265,11 +284,10 @@ private Set<Long> applyCategoryWiseReordering(

// 2-5) 최종 리스트 확정
List<Task> finalSortedTasks = Arrays.stream(slots).filter(Objects::nonNull).toList();

log.debug("[TASK][REORDER] 최종 정렬 후 position 재할당 (bucket={},{}):",
categoryId, dueDate);

// 2-6) 임시 pos 부여(유니크키 충돌 회피) + 카테고리 변경 반영 → flush
// 2-6) 카테고리 변경 및 임시 position 부여 → flush (유니크키 충돌 방지)
for (int i = 0; i < finalSortedTasks.size(); i++) {
Task task = finalSortedTasks.get(i);

Expand All @@ -278,7 +296,6 @@ private Set<Long> applyCategoryWiseReordering(
log.debug(" - taskId={} 카테고리 변경: {} → {}", task.getId(), task.getCategory().getId(), categoryId);
task.updateCategory(category);
}

task.updatePosition(TEMP_BASE + i);
}
taskRepository.flush(); // 중간 flush로 일시 중복 상태 제거
Expand Down Expand Up @@ -340,7 +357,7 @@ private List<TaskOrderCategoryUpdateRequestDTO> validateTaskOrdersRequest(TaskOr
log.warn("[TASK] 빈 taskOrder 요청");
throw new GeneralException(ErrorStatus.TASK_INVALID_ORDER);
}
// 추가 방어: taskId 중복 및 order 음수 방지
// taskId 중복 및 order 음수 방지
Set<Long> seen = new HashSet<>();
for (TaskOrderCategoryUpdateRequestDTO t : tasks) {
if (t.getTaskId() == null || !seen.add(t.getTaskId())) {
Expand Down