Skip to content

Commit 7891377

Browse files
refactor: 동정 요청 시각 데이터의 중복을 제거 & 공개/비공개 전환 시 동정 요청 처리 로직 개선 (#223)
* refactor: birdIdSuggestionRequestedAt 필드 제거 및 BirdIdRequestHistory 활용으로 구조 변경 동정 요청 시각 데이터의 중복을 제거하기 위함 (기존 UserBirdCollection.birdIdSuggestionRequestedAt -> BirdIdRequestHistory.startedAt으로 통합) * test: 테스트 실패 해결
1 parent 58b69e8 commit 7891377

10 files changed

Lines changed: 178 additions & 95 deletions

File tree

src/main/java/org/devkor/apu/saerok_server/domain/collection/application/BirdIdSuggestionQueryService.java

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@
99
import org.devkor.apu.saerok_server.domain.user.core.entity.User;
1010
import org.devkor.apu.saerok_server.domain.user.core.repository.UserRepository;
1111
import org.devkor.apu.saerok_server.domain.user.core.service.UserProfileImageUrlService;
12+
import org.devkor.apu.saerok_server.domain.stat.core.repository.BirdIdRequestHistoryRepository; // ★ 추가
1213
import org.devkor.apu.saerok_server.global.shared.exception.NotFoundException;
1314
import org.devkor.apu.saerok_server.global.shared.infra.ImageDomainService;
1415
import org.devkor.apu.saerok_server.global.shared.util.OffsetDateTimeLocalizer;
1516
import org.springframework.stereotype.Service;
1617
import org.springframework.transaction.annotation.Transactional;
1718

19+
import java.time.OffsetDateTime; // ★ 추가
1820
import java.util.List;
1921
import java.util.Map;
2022

@@ -26,9 +28,10 @@ public class BirdIdSuggestionQueryService {
2628
private final BirdIdSuggestionRepository suggestionRepo;
2729
private final CollectionRepository collectionRepo;
2830
private final ImageDomainService imageDomainService;
29-
private final UserRepository userRepo;
31+
private final UserRepository userRepo;
3032
private final UserProfileImageUrlService userProfileImageUrlService;
31-
private final CollectionImageUrlService collectionImageUrlService;
33+
private final CollectionImageUrlService collectionImageUrlService;
34+
private final BirdIdRequestHistoryRepository birdIdRequestHistoryRepository; // ★ 추가
3235

3336
/* 전체 PUBLIC + pending 컬렉션 조회 */
3437
public GetPendingCollectionsResponse getPendingCollections() {
@@ -47,18 +50,27 @@ public GetPendingCollectionsResponse getPendingCollections() {
4750
.toList();
4851
Map<Long, String> profileImageUrls = userProfileImageUrlService.getProfileImageUrlsFor(users);
4952

53+
// ── 3.5단계: 열린 동정 요청 히스토리 startedAt 일괄 조회 (기존 c.getBirdIdSuggestionRequestedAt() 대체)
54+
List<Long> ids = collections.stream().map(UserBirdCollection::getId).toList();
55+
Map<Long, OffsetDateTime> startedAtMap = birdIdRequestHistoryRepository.findOpenStartedAtMapByCollectionIds(ids);
56+
5057
// ── 4단계: DTO 조립
5158
List<GetPendingCollectionsResponse.Item> items = collections.stream()
52-
.map(c -> new GetPendingCollectionsResponse.Item(
53-
c.getId(),
54-
thumbMap.getOrDefault(c.getId(), null) == null
55-
? null
56-
: imageDomainService.toUploadImageUrl(thumbMap.get(c.getId())),
57-
c.getNote(),
58-
c.getUser().getNickname(),
59-
profileImageUrls.get(c.getUser().getId()),
60-
OffsetDateTimeLocalizer.toSeoulLocalDateTime(c.getBirdIdSuggestionRequestedAt())
61-
))
59+
.map(c -> {
60+
OffsetDateTime startedAt = startedAtMap.get(c.getId()); // ★ 변경: 히스토리 기준
61+
return new GetPendingCollectionsResponse.Item(
62+
c.getId(),
63+
thumbMap.getOrDefault(c.getId(), null) == null
64+
? null
65+
: imageDomainService.toUploadImageUrl(thumbMap.get(c.getId())),
66+
c.getNote(),
67+
c.getUser().getNickname(),
68+
profileImageUrls.get(c.getUser().getId()),
69+
startedAt != null
70+
? OffsetDateTimeLocalizer.toSeoulLocalDateTime(startedAt)
71+
: null
72+
);
73+
})
6274
.toList();
6375

6476
return new GetPendingCollectionsResponse(items);

src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommandService.java

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import org.devkor.apu.saerok_server.domain.collection.application.dto.CreateCollectionCommand;
77
import org.devkor.apu.saerok_server.domain.collection.application.dto.DeleteCollectionCommand;
88
import org.devkor.apu.saerok_server.domain.collection.application.dto.UpdateCollectionCommand;
9+
import org.devkor.apu.saerok_server.domain.collection.core.entity.AccessLevelType;
910
import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollection;
1011
import org.devkor.apu.saerok_server.domain.collection.core.repository.CollectionImageRepository;
1112
import org.devkor.apu.saerok_server.domain.collection.core.repository.CollectionRepository;
@@ -71,7 +72,7 @@ public Long createCollection(CreateCollectionCommand command) {
7172

7273
Long id = collectionRepository.save(collection);
7374

74-
// 생성 직후 bird가 비어 있으면 '대기 시작' 기록
75+
// 생성 직후 bird가 비어 있고 PUBLIC이면 '대기 시작' 기록
7576
birdReqHistory.onCollectionCreatedIfPending(collection, collection.getCreatedAt());
7677

7778
return id;
@@ -92,7 +93,7 @@ public void deleteCollection(DeleteCollectionCommand command) {
9293
collectionImageRepository.removeByCollectionId(command.collectionId());
9394
collectionRepository.remove(collection);
9495
runAfterCommitOrNow(() -> imageService.deleteAll(objectKeys));
95-
// 3) 닫힌 히스토리는 FK가 SET NULL로 남는다 (DB가 처리)
96+
// 3) 닫힌 히스토리는 FK가 SET NULL로 남는다
9697
}
9798

9899
public UpdateCollectionResponse updateCollection(UpdateCollectionCommand command) {
@@ -102,22 +103,23 @@ public UpdateCollectionResponse updateCollection(UpdateCollectionCommand command
102103
throw new ForbiddenException("해당 컬렉션에 대한 권한이 없어요");
103104
}
104105

105-
if (command.isBirdIdUpdated() != null && command.isBirdIdUpdated()) {
106-
Bird before = collection.getBird();
107-
Bird after;
106+
OffsetDateTime now = OffsetDateTime.now();
107+
// 변경 전 상태 스냅샷
108+
AccessLevelType oldLevel = collection.getAccessLevel();
108109

109-
if (command.birdId() != null) {
110-
after = birdRepository.findById(command.birdId()).orElseThrow(() -> new NotFoundException("존재하지 않는 조류 id예요"));
111-
} else {
112-
after = null;
113-
}
110+
// 새 ID 변경
111+
if (Boolean.TRUE.equals(command.isBirdIdUpdated())) {
112+
Bird before = collection.getBird();
113+
Bird after = (command.birdId() != null)
114+
? birdRepository.findById(command.birdId()).orElseThrow(() -> new NotFoundException("존재하지 않는 조류 id예요"))
115+
: null;
114116

115-
// null->not null : '수정(EDIT)' 경로 → 열린 history 삭제(해결로 간주하지 않음)
116117
if (before == null && after != null) {
118+
// null -> not null : EDIT로 해결 → 열린 기록 삭제
117119
birdReqHistory.onResolvedByEdit(collection);
118120
} else if (before != null && after == null) {
119-
// not null->null : 다시 대기 시작
120-
birdReqHistory.onBirdSetToUnknown(collection, OffsetDateTime.now());
121+
// not null -> null : PUBLIC이면 다시 대기 시작
122+
birdReqHistory.onBirdSetToUnknown(collection, now);
121123
}
122124

123125
collection.changeBird(after);
@@ -142,8 +144,10 @@ public UpdateCollectionResponse updateCollection(UpdateCollectionCommand command
142144
collection.setNote(command.note());
143145
}
144146

147+
// 액세스 레벨 변경 처리 (전/후 비교)
145148
if (command.accessLevel() != null) {
146149
collection.setAccessLevel(command.accessLevel());
150+
birdReqHistory.onAccessLevelChanged(collection, oldLevel, now);
147151
}
148152

149153
String imageUrl = collectionImageRepository.findObjectKeysByCollectionId(command.collectionId()).stream()

src/main/java/org/devkor/apu/saerok_server/domain/collection/core/entity/UserBirdCollection.java

Lines changed: 8 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,13 @@
11
package org.devkor.apu.saerok_server.domain.collection.core.entity;
22

33
import jakarta.persistence.*;
4-
import lombok.Builder;
5-
import lombok.Getter;
6-
import lombok.NoArgsConstructor;
7-
import lombok.Setter;
4+
import lombok.*;
85
import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.Bird;
96
import org.devkor.apu.saerok_server.domain.user.core.entity.User;
107
import org.devkor.apu.saerok_server.global.shared.entity.Auditable;
118
import org.locationtech.jts.geom.Point;
129

1310
import java.time.LocalDate;
14-
import java.time.OffsetDateTime;
1511

1612
@Entity
1713
@Getter
@@ -32,6 +28,9 @@ public class UserBirdCollection extends Auditable {
3228
@JoinColumn(name = "bird_id", nullable = true)
3329
private Bird bird;
3430

31+
/**
32+
* 임시 새 이름 — 현재 미사용
33+
*/
3534
@Column(name = "temp_bird_name")
3635
private String tempBirdName;
3736

@@ -63,10 +62,6 @@ public class UserBirdCollection extends Auditable {
6362
@Setter
6463
private AccessLevelType accessLevel;
6564

66-
@Column(name = "bird_id_suggestion_requested_at")
67-
@Setter
68-
private OffsetDateTime birdIdSuggestionRequestedAt;
69-
7065
@Builder
7166
public UserBirdCollection(User user, Bird bird, String tempBirdName, LocalDate discoveredDate, Point location, String locationAlias, String address, String note, boolean isPinned, AccessLevelType accessLevel) {
7267

@@ -75,11 +70,7 @@ public UserBirdCollection(User user, Bird bird, String tempBirdName, LocalDate d
7570
if (location == null) throw new IllegalArgumentException("location은 null일 수 없습니다.");
7671

7772
this.user = user;
78-
if (bird != null) {
79-
this.bird = bird;
80-
} else {
81-
this.birdIdSuggestionRequestedAt = OffsetDateTime.now();
82-
}
73+
this.bird = bird;
8374
this.tempBirdName = tempBirdName;
8475
this.discoveredDate = discoveredDate;
8576
this.location = location;
@@ -90,25 +81,12 @@ public UserBirdCollection(User user, Bird bird, String tempBirdName, LocalDate d
9081
this.accessLevel = accessLevel == null ? AccessLevelType.PUBLIC : accessLevel;
9182
}
9283

93-
/**
94-
* bird가 변경됨에 따라 birdIdSuggestionRequestedAt이 변경되는 규칙.
95-
*/
84+
/** 단순 변경: 동정 요청 기록 열고/닫기는 별도 Recorder가 처리 */
9685
public void changeBird(Bird newBird) {
97-
boolean wasPresent = this.bird != null;
9886
this.bird = newBird;
99-
100-
if (wasPresent && newBird == null) {
101-
this.birdIdSuggestionRequestedAt = OffsetDateTime.now();
102-
} else if (newBird != null) {
103-
this.birdIdSuggestionRequestedAt = null;
104-
}
10587
}
10688

107-
public double getLongitude() {
108-
return location.getX();
109-
}
89+
public double getLongitude() { return location.getX(); }
11090

111-
public double getLatitude() {
112-
return location.getY();
113-
}
91+
public double getLatitude() { return location.getY(); }
11492
}

src/main/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionRepository.java

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -116,15 +116,14 @@ public void prefetchUserAndBirdByIds(List<Long> collectionIds) {
116116
.getResultList();
117117
}
118118

119-
/** bird_id 가 비어 있고 공개(PUBLIC)인 컬렉션 조회 + 작성자 fetch join */
120119
public List<UserBirdCollection> findPublicPendingCollections() {
121120
return em.createQuery("""
122-
SELECT c FROM UserBirdCollection c
123-
JOIN FETCH c.user u
124-
WHERE c.accessLevel = :public
125-
AND c.bird IS NULL
126-
ORDER BY c.birdIdSuggestionRequestedAt DESC
127-
""", UserBirdCollection.class)
121+
SELECT c FROM UserBirdCollection c
122+
JOIN FETCH c.user u
123+
JOIN BirdIdRequestHistory h ON h.collection.id = c.id AND h.resolvedAt IS NULL
124+
WHERE c.accessLevel = :public AND c.bird IS NULL
125+
ORDER BY h.startedAt DESC
126+
""", UserBirdCollection.class)
128127
.setParameter("public", AccessLevelType.PUBLIC)
129128
.getResultList();
130129
}

src/main/java/org/devkor/apu/saerok_server/domain/community/core/repository/CommunityRepository.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,14 @@ public List<UserBirdCollection> findPopularCollections(CommunityQueryCommand com
4848
return query.getResultList();
4949
}
5050

51-
// 동정 요청 컬렉션들을 조회
51+
// 동정 요청 컬렉션들을 조회 — 열린 히스토리 startedAt 최신순
5252
public List<UserBirdCollection> findPendingBirdIdCollections(CommunityQueryCommand command) {
5353
Query query = em.createQuery("""
5454
SELECT c FROM UserBirdCollection c
5555
JOIN FETCH c.user u
56+
JOIN BirdIdRequestHistory h ON h.collection.id = c.id AND h.resolvedAt IS NULL
5657
WHERE c.accessLevel = :public AND c.bird IS NULL
57-
ORDER BY c.birdIdSuggestionRequestedAt DESC
58+
ORDER BY h.startedAt DESC
5859
""", UserBirdCollection.class)
5960
.setParameter("public", AccessLevelType.PUBLIC);
6061

src/main/java/org/devkor/apu/saerok_server/domain/stat/application/BirdIdRequestHistoryRecorder.java

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import jakarta.transaction.Transactional;
44
import lombok.RequiredArgsConstructor;
5+
import org.devkor.apu.saerok_server.domain.collection.core.entity.AccessLevelType;
56
import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollection;
67
import org.devkor.apu.saerok_server.domain.stat.core.entity.BirdIdRequestHistory;
78
import org.devkor.apu.saerok_server.domain.stat.core.entity.ResolutionKind;
@@ -10,16 +11,20 @@
1011

1112
import java.time.OffsetDateTime;
1213

14+
import static org.devkor.apu.saerok_server.domain.collection.core.entity.AccessLevelType.*;
15+
import static org.devkor.apu.saerok_server.domain.collection.core.entity.AccessLevelType.PUBLIC;
16+
1317
@Component
1418
@RequiredArgsConstructor
1519
@Transactional
1620
public class BirdIdRequestHistoryRecorder {
1721

1822
private final BirdIdRequestHistoryRepository repo;
1923

20-
/** 컬렉션 생성 직후, bird가 비어있는 경우 pending 시작을 기록 */
24+
/** 컬렉션 생성 직후, bird가 비어있고 PUBLIC인 경우 pending 시작을 기록 */
2125
public void onCollectionCreatedIfPending(UserBirdCollection collection, OffsetDateTime startedAt) {
2226
if (collection.getBird() != null) return;
27+
if (collection.getAccessLevel() != PUBLIC) return;
2328
if (repo.findOpenByCollectionId(collection.getId()).isPresent()) return;
2429
repo.save(BirdIdRequestHistory.start(collection, startedAt));
2530
}
@@ -35,12 +40,27 @@ public void onResolvedByEdit(UserBirdCollection collection) {
3540
repo.deleteOpenByCollectionId(collection.getId());
3641
}
3742

38-
/** not null -> null 로 바뀌는 순간: 새 pending 시작 */
43+
/** not null -> null 로 바뀌는 순간: PUBLIC이면 새 pending 시작 */
3944
public void onBirdSetToUnknown(UserBirdCollection collection, OffsetDateTime startedAt) {
45+
if (collection.getAccessLevel() != PUBLIC) return;
4046
if (repo.findOpenByCollectionId(collection.getId()).isPresent()) return;
4147
repo.save(BirdIdRequestHistory.start(collection, startedAt));
4248
}
4349

50+
/** 액세스 레벨 전환 시 후처리 */
51+
public void onAccessLevelChanged(UserBirdCollection collection, AccessLevelType oldLevel, OffsetDateTime now) {
52+
AccessLevelType newLevel = collection.getAccessLevel();
53+
if (oldLevel == PUBLIC && newLevel == PRIVATE) {
54+
// 열려 있던 동정 요청 취소(삭제)
55+
repo.deleteOpenByCollectionId(collection.getId());
56+
} else if (oldLevel == PRIVATE && newLevel == PUBLIC) {
57+
// 공개로 바뀌었고 아직 미식별이면 새로 오픈
58+
if (collection.getBird() == null && repo.findOpenByCollectionId(collection.getId()).isEmpty()) {
59+
repo.save(BirdIdRequestHistory.start(collection, now));
60+
}
61+
}
62+
}
63+
4464
/** 컬렉션을 삭제하기 직전: 열린 히스토리만 정리(삭제) */
4565
public void onCollectionDeleted(Long collectionId) {
4666
repo.deleteOpenByCollectionId(collectionId);

src/main/java/org/devkor/apu/saerok_server/domain/stat/core/repository/BirdIdRequestHistoryRepository.java

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import org.springframework.stereotype.Repository;
88

99
import java.time.OffsetDateTime;
10-
import java.util.Optional;
10+
import java.util.*;
1111

1212
@Repository
1313
@RequiredArgsConstructor
@@ -38,6 +38,25 @@ public void deleteOpenByCollectionId(Long collectionId) {
3838
.executeUpdate();
3939
}
4040

41+
/** 여러 컬렉션에 대해 열린 히스토리의 startedAt을 한 번에 조회 */
42+
public Map<Long, OffsetDateTime> findOpenStartedAtMapByCollectionIds(List<Long> collectionIds) {
43+
if (collectionIds == null || collectionIds.isEmpty()) return Map.of();
44+
45+
List<Object[]> rows = em.createQuery("""
46+
SELECT h.collection.id, h.startedAt
47+
FROM BirdIdRequestHistory h
48+
WHERE h.collection.id IN :ids AND h.resolvedAt IS NULL
49+
""", Object[].class)
50+
.setParameter("ids", collectionIds)
51+
.getResultList();
52+
53+
Map<Long, OffsetDateTime> map = new HashMap<>();
54+
for (Object[] row : rows) {
55+
map.put((Long) row[0], (OffsetDateTime) row[1]);
56+
}
57+
return map;
58+
}
59+
4160
public long countPendingAsOf(OffsetDateTime endExclusive) {
4261
return em.createQuery("""
4362
SELECT COUNT(h) FROM BirdIdRequestHistory h
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
-- 1) PRIVATE 컬렉션(그리고 bird IS NULL)에 열려 있는(미해결) 동정 요청 기록 삭제
2+
DELETE FROM bird_id_request_history h
3+
USING user_bird_collection c
4+
WHERE h.collection_id = c.id
5+
AND h.resolved_at IS NULL
6+
AND c.access_level = 'PRIVATE'
7+
AND c.bird_id IS NULL;
8+
9+
-- 2) PUBLIC 이면서 bird IS NULL인데, 아직 열린 동정 요청 기록이 없는 컬렉션에 대해 오픈 히스토리 백필
10+
-- started_at은 기존 컬럼이 남아있다면 우선 사용, 없으면 updated_at으로 보정
11+
INSERT INTO bird_id_request_history (id, collection_id, started_at, created_at)
12+
SELECT nextval('bird_id_request_history_seq'),
13+
c.id,
14+
COALESCE(c.bird_id_suggestion_requested_at, c.updated_at),
15+
COALESCE(c.bird_id_suggestion_requested_at, c.updated_at)
16+
FROM user_bird_collection c
17+
LEFT JOIN LATERAL (
18+
SELECT 1
19+
FROM bird_id_request_history h
20+
WHERE h.collection_id = c.id
21+
AND h.resolved_at IS NULL
22+
LIMIT 1
23+
) open ON true
24+
WHERE open IS NULL
25+
AND c.access_level = 'PUBLIC'
26+
AND c.bird_id IS NULL;
27+
28+
-- 3) 더 이상 사용하지 않는 컬럼 제거
29+
ALTER TABLE user_bird_collection
30+
DROP COLUMN IF EXISTS bird_id_suggestion_requested_at;

0 commit comments

Comments
 (0)