Skip to content

Commit d3d96f2

Browse files
Merge pull request #97 from Finders-Official/develop
[RELEASE] v0.3.3 (#96)
2 parents 5fe64c1 + ad5930e commit d3d96f2

49 files changed

Lines changed: 2080 additions & 31 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/architecture/ERD.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Finders ERD
22

33
> 필름 현상소 예약 서비스 데이터베이스 설계서
4-
> v2.4.0 | 2026-01-06
4+
> v2.4.4 | 2026-01-12
55
66
---
77

@@ -20,6 +20,7 @@
2020
| | `terms` | 약관 버전 관리 |
2121
| | `token_history` | AI 토큰 충전/사용 내역 - User 전용 |
2222
| **store** | `photo_lab` | 현상소 정보 |
23+
| | `region` | 지역 (시/도, 시/군/구) |
2324
| | `photo_lab_image` | 현상소 이미지 |
2425
| | `photo_lab_keyword` | 현상소 키워드/태그 |
2526
| | `photo_lab_notice` | 현상소 공지사항 |
@@ -366,9 +367,23 @@ CREATE TABLE token_history ( -- AI 토큰 충전/사용 내역
366367
-- 2. STORE (현상소)
367368
-- ============================================
368369

370+
CREATE TABLE region (
371+
id BIGINT NOT NULL AUTO_INCREMENT,
372+
sigungu VARCHAR(50) NOT NULL, -- 시/군/구
373+
sido BIGINT NULL, -- 상위 시/도 (region.id)
374+
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
375+
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
376+
deleted_at DATETIME NULL,
377+
PRIMARY KEY (id),
378+
INDEX idx_region_sido (sido),
379+
UNIQUE KEY uk_region_sigungu_sido (sigungu, sido),
380+
CONSTRAINT fk_region_sido FOREIGN KEY (sido) REFERENCES region(id)
381+
) ENGINE=InnoDB COMMENT='지역 (시/도, 시/군/구)';
382+
369383
CREATE TABLE photo_lab (
370384
id BIGINT NOT NULL AUTO_INCREMENT,
371385
owner_id BIGINT NOT NULL, -- FK → member_owner.member_id (Owner 전용)
386+
region_id BIGINT NOT NULL, -- FK region.id (시/군/구)
372387
name VARCHAR(100) NOT NULL,
373388
description TEXT NULL,
374389
phone VARCHAR(20) NULL,
@@ -393,6 +408,7 @@ CREATE TABLE photo_lab (
393408
INDEX idx_lab_location (latitude, longitude),
394409
FULLTEXT INDEX ft_lab_name (name),
395410
CONSTRAINT fk_lab_owner FOREIGN KEY (owner_id) REFERENCES member(id),
411+
CONSTRAINT fk_lab_region FOREIGN KEY (region_id) REFERENCES region(id),
396412
CONSTRAINT chk_lab_status CHECK (status IN ('PENDING', 'ACTIVE', 'SUSPENDED', 'CLOSED'))
397413
) ENGINE=InnoDB COMMENT='현상소';
398414

@@ -942,3 +958,4 @@ CREATE TABLE payment ( -- 포트원 V2 결제 연동
942958
| 2.4.1 | 2026-01-07 | **예약 슬롯 엔티티 추가**: `reservation_slot` 테이블 신규 도입. 현상소(`photo_lab`) + 날짜 + 시간 단위의 예약 정원(`max_capacity`, `reserved_count`)을 관리하도록 구조 분리. 동시 예약 시 정원 초과를 방지하기 위해 슬롯 단위 락 기반 처리 적용. |
943959
| 2.4.2 | 2026-01-08 | `member` 테이블의 `dtype` 컬럼명 `role`로 수정, `social_account` 테이블에 email 컬럼 추가 |
944960
| 2.4.3 | 2026-01-08 | `member` 테이블의 `profile_image` 컬럼 `member_user` 테이블로 이동 및 `role` 관련 제약 조건 알맞게 수정 |
961+
| 2.4.4 | 2026-01-12 | `region` table (sido/sigungu) 추가 및 `photo_lab.region_id` FK 참조 설정

docs/infra/GCP_LOGGING_GUIDE.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,10 +127,14 @@ gcloud auth application-default login \
127127
128128
### 4. 애플리케이션 실행
129129

130+
**터미널에서 실행:**
130131
```bash
131132
./gradlew bootRun
132133
```
133134

135+
**IntelliJ에서 실행하는 경우:**
136+
> ADC 설정 후 **IntelliJ를 완전히 재시작**해야 합니다. (프로젝트 닫기가 아닌 IntelliJ 종료 후 재시작)
137+
134138
### 5. Swagger에서 테스트
135139

136140
http://localhost:8080/swagger-ui.html 접속 → **[TEST] Storage** 섹션
@@ -177,6 +181,15 @@ http://localhost:8080/swagger-ui.html 접속 → **[TEST] Storage** 섹션
177181

178182
### 트러블슈팅
179183

184+
#### "401 Unauthorized" 오류
185+
- ADC 설정이 안 되어 있습니다 → 3번 단계 실행
186+
- IntelliJ 사용 시 → IntelliJ 완전히 재시작 (종료 후 다시 시작)
187+
- 기존 ADC가 잘못된 경우:
188+
```bash
189+
gcloud auth application-default revoke # 기존 설정 삭제
190+
# 3번 단계 다시 실행
191+
```
192+
180193
#### "403 Forbidden" 오류
181194
- Impersonation이 만료되었을 수 있습니다 → 3번 단계 다시 실행
182195
- 서버 재시작 후 다시 시도

src/main/java/com/finders/api/domain/community/controller/PostController.java

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
import io.swagger.v3.oas.annotations.tags.Tag;
1717
import jakarta.validation.Valid;
1818
import lombok.RequiredArgsConstructor;
19+
import org.springframework.data.domain.Pageable;
20+
import org.springframework.data.domain.Sort;
21+
import org.springframework.data.web.PageableDefault;
1922
import org.springframework.http.MediaType;
2023
import org.springframework.security.core.annotation.AuthenticationPrincipal;
2124
import org.springframework.web.bind.annotation.*;
@@ -126,10 +129,29 @@ public ApiResponse<PostResponse.PostPreviewListDTO> getPopularPosts(
126129
return ApiResponse.success(SuccessCode.POST_FOUND, postQueryService.getPopularPosts(memberUser));
127130
}
128131

129-
// // 현상소 관련
130-
// @Operation(summary = "현상소 검색", description = "게시글 작성 시 연결할 현상소를 검색합니다.")
131-
// @GetMapping("/labs")
132-
// public ApiResponse<String> searchLabs(@RequestParam(required = false) String query) {
133-
// return ApiResponse.success(SuccessCode.STORE_LIST_FOUND, "현상소 검색 성공: " + query);
134-
// }
132+
// 커뮤니티 게시물 검색
133+
@Operation(summary = "사진 수다 게시글 검색", description = "사진 수다 페이지에서 게시글을 검색합니다.")
134+
@GetMapping("/search")
135+
public ApiResponse<PostResponse.PostPreviewListDTO> searchPosts(
136+
@RequestParam(name = "keyword") String keyword,
137+
@AuthenticationPrincipal MemberUser memberUser,
138+
@PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable
139+
) {
140+
return ApiResponse.success(SuccessCode.POST_FOUND, postQueryService.searchPosts(keyword, memberUser, pageable));
141+
}
142+
143+
// 현상소 검색
144+
@Operation(summary = "현상소 검색", description = "게시글 작성 시 연결할 현상소를 검색합니다. 위도/경도가 없으면 거리 없이 주소만 나옵니다.")
145+
@GetMapping("/labs")
146+
public ApiResponse<PostResponse.PhotoLabSearchListDTO> searchLabs(
147+
@RequestParam(name = "keyword") String keyword,
148+
@RequestParam(name = "latitude", required = false) Double latitude,
149+
@RequestParam(name = "longitude", required = false) Double longitude,
150+
@PageableDefault(size = 8) Pageable pageable
151+
) {
152+
return ApiResponse.success(
153+
SuccessCode.STORE_LIST_FOUND,
154+
postQueryService.searchPhotoLabs(keyword, latitude, longitude, pageable)
155+
);
156+
}
135157
}

src/main/java/com/finders/api/domain/community/dto/response/PostResponse.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.finders.api.domain.community.dto.response;
22

33
import com.finders.api.domain.community.entity.Post;
4+
import com.finders.api.domain.store.entity.PhotoLab;
45
import lombok.Builder;
56

67
import java.time.LocalDateTime;
@@ -107,4 +108,32 @@ public static PostPreviewListDTO from(List<PostPreviewDTO> previewDTOs) {
107108
return new PostPreviewListDTO(previewDTOs);
108109
}
109110
}
111+
112+
// 현상소 검색
113+
// 개별 항목 DTO
114+
@Builder
115+
public record PhotoLabSearchDTO(
116+
Long labId,
117+
String name,
118+
String address,
119+
String distance
120+
) {
121+
public static PhotoLabSearchDTO from(PhotoLab photoLab, String distance) {
122+
return PhotoLabSearchDTO.builder()
123+
.labId(photoLab.getId())
124+
.name(photoLab.getName())
125+
.address(photoLab.getAddress())
126+
.distance(distance)
127+
.build();
128+
}
129+
}
130+
131+
// 현상소 검색 리스트를 감싸는 DTO
132+
public record PhotoLabSearchListDTO(
133+
List<PhotoLabSearchDTO> photoLabSearchList
134+
) {
135+
public static PhotoLabSearchListDTO from(List<PhotoLabSearchDTO> dtos) {
136+
return new PhotoLabSearchListDTO(dtos);
137+
}
138+
}
110139
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package com.finders.api.domain.community.entity;
2+
3+
import com.finders.api.domain.member.entity.MemberUser;
4+
import com.finders.api.domain.store.entity.PhotoLab;
5+
import com.finders.api.global.entity.BaseTimeEntity;
6+
import jakarta.persistence.Entity;
7+
import jakarta.persistence.FetchType;
8+
import jakarta.persistence.GeneratedValue;
9+
import jakarta.persistence.GenerationType;
10+
import jakarta.persistence.Id;
11+
import jakarta.persistence.Index;
12+
import jakarta.persistence.JoinColumn;
13+
import jakarta.persistence.ManyToOne;
14+
import jakarta.persistence.Table;
15+
import jakarta.persistence.UniqueConstraint;
16+
import lombok.AccessLevel;
17+
import lombok.Getter;
18+
import lombok.NoArgsConstructor;
19+
20+
@Entity
21+
@Table(
22+
name = "favorite_photo_lab",
23+
uniqueConstraints = {
24+
@UniqueConstraint(name = "uk_favorite_member_lab", columnNames = {"member_id", "photo_lab_id"})
25+
},
26+
indexes = {
27+
@Index(name = "idx_favorite_member", columnList = "member_id"),
28+
@Index(name = "idx_favorite_lab", columnList = "photo_lab_id")
29+
}
30+
)
31+
@Getter
32+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
33+
public class FavoritePhotoLab extends BaseTimeEntity {
34+
35+
@Id
36+
@GeneratedValue(strategy = GenerationType.IDENTITY)
37+
private Long id;
38+
39+
@ManyToOne(fetch = FetchType.LAZY)
40+
@JoinColumn(name = "member_id", nullable = false)
41+
private MemberUser member;
42+
43+
@ManyToOne(fetch = FetchType.LAZY)
44+
@JoinColumn(name = "photo_lab_id", nullable = false)
45+
private PhotoLab photoLab;
46+
47+
public FavoritePhotoLab(MemberUser member, PhotoLab photoLab) {
48+
this.member = member;
49+
this.photoLab = photoLab;
50+
}
51+
}

src/main/java/com/finders/api/domain/community/repository/PostRepository.java

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package com.finders.api.domain.community.repository;
22

33
import com.finders.api.domain.community.entity.Post;
4+
import com.finders.api.domain.store.entity.PhotoLab;
5+
import org.springframework.data.domain.Page;
6+
import org.springframework.data.domain.Pageable;
47
import org.springframework.data.jpa.repository.JpaRepository;
58
import org.springframework.data.jpa.repository.Query;
69
import org.springframework.data.repository.query.Param;
@@ -13,4 +16,19 @@ public interface PostRepository extends JpaRepository<Post, Long> {
1316
"LEFT JOIN FETCH p.photoLab " +
1417
"WHERE p.id = :id AND p.status = 'ACTIVE'")
1518
Optional<Post> findByIdWithDetails(@Param("id") Long id);
19+
20+
// 커뮤니티 게시글 검색
21+
@Query("SELECT p FROM Post p " +
22+
"LEFT JOIN p.photoLab pl " +
23+
"WHERE p.status = 'ACTIVE' " +
24+
"AND (p.title LIKE %:keyword% " +
25+
"OR p.content LIKE %:keyword% " +
26+
"OR pl.name LIKE %:keyword%)")
27+
Page<Post> searchPostsByKeyword(@Param("keyword") String keyword, Pageable pageable);
28+
29+
// 현상소 검색
30+
@Query("SELECT pl FROM PhotoLab pl " +
31+
"WHERE pl.status = 'ACTIVE' " +
32+
"AND pl.name LIKE %:keyword%")
33+
Page<PhotoLab> searchByName(@Param("keyword") String keyword, Pageable pageable);
1634
}

src/main/java/com/finders/api/domain/community/service/query/PostQueryService.java

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

33
import com.finders.api.domain.community.dto.response.PostResponse;
44
import com.finders.api.domain.member.entity.MemberUser;
5+
import org.springframework.data.domain.Pageable;
56

67
public interface PostQueryService {
78
PostResponse.PostPreviewListDTO getPostList(Integer page);
89

910
PostResponse.PostDetailResDTO getPostDetail(Long postId, MemberUser memberUser);
1011

1112
PostResponse.PostPreviewListDTO getPopularPosts(MemberUser memberUser);
13+
14+
PostResponse.PostPreviewListDTO searchPosts(String keyword, MemberUser memberUser, Pageable pageable);
15+
16+
// 현상소 검색
17+
PostResponse.PhotoLabSearchListDTO searchPhotoLabs(String keyword, Double latitude, Double longitude, Pageable pageable);
1218
}

src/main/java/com/finders/api/domain/community/service/query/PostQueryServiceImpl.java

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@
66
import com.finders.api.domain.community.repository.PostQueryRepository;
77
import com.finders.api.domain.community.repository.PostRepository;
88
import com.finders.api.domain.member.entity.MemberUser;
9+
import com.finders.api.domain.store.entity.PhotoLab;
910
import com.finders.api.global.exception.CustomException;
1011
import com.finders.api.global.response.ErrorCode;
1112
import com.finders.api.infra.storage.StorageService;
1213
import lombok.RequiredArgsConstructor;
14+
import org.springframework.data.domain.Page;
15+
import org.springframework.data.domain.Pageable;
1316
import org.springframework.stereotype.Service;
1417
import org.springframework.transaction.annotation.Transactional;
1518

@@ -24,6 +27,12 @@ public class PostQueryServiceImpl implements PostQueryService {
2427
private static final int DEFAULT_PAGE_SIZE = 10;
2528
private static final int SIGNED_URL_EXPIRY_MINUTES = 60;
2629

30+
// 현상소 검색 관련
31+
private static final String DISTANCE_FORMAT_KM = "%.1fkm";
32+
private static final int MINUTES_IN_DEGREE = 60;
33+
private static final double STATUTE_MILES_PER_NAUTICAL_MILE = 1.1515;
34+
private static final double KILOMETERS_PER_STATUTE_MILE = 1.609344;
35+
2736
private final PostRepository postRepository;
2837
private final PostLikeRepository postLikeRepository;
2938
private final PostQueryRepository postQueryRepository;
@@ -82,12 +91,71 @@ public PostResponse.PostPreviewListDTO getPopularPosts(MemberUser memberUser) {
8291
})
8392
.toList();
8493

85-
// 4. 리스트를 감싸서 반환
8694
return PostResponse.PostPreviewListDTO.from(previewDTOs);
8795
}
8896

8997
private String getFullUrl(String path) {
9098
if (path == null || path.isBlank()) return null;
9199
return storageService.getSignedUrl(path, SIGNED_URL_EXPIRY_MINUTES).url();
92100
}
101+
102+
// 커뮤니티 게시글 검색
103+
@Override
104+
public PostResponse.PostPreviewListDTO searchPosts(String keyword, MemberUser memberUser, Pageable pageable) {
105+
Page<Post> posts = postRepository.searchPostsByKeyword(keyword, pageable);
106+
107+
Set<Long> likedPostIds = java.util.Collections.emptySet();
108+
if (memberUser != null) {
109+
List<Long> postIds = posts.getContent().stream().map(Post::getId).toList();
110+
likedPostIds = postLikeRepository.findLikedPostIdsByMemberAndPostIds(memberUser.getId(), postIds);
111+
}
112+
113+
final Set<Long> finalLikedPostIds = likedPostIds;
114+
List<PostResponse.PostPreviewDTO> dtos = posts.getContent().stream()
115+
.map(post -> {
116+
boolean isLiked = finalLikedPostIds.contains(post.getId());
117+
String mainImageUrl = post.getPostImageList().isEmpty() ? null
118+
: getFullUrl(post.getPostImageList().get(0).getImageUrl());
119+
return PostResponse.PostPreviewDTO.from(post, isLiked, mainImageUrl);
120+
})
121+
.toList();
122+
123+
return PostResponse.PostPreviewListDTO.from(dtos);
124+
}
125+
126+
// 현상소 검색
127+
@Override
128+
public PostResponse.PhotoLabSearchListDTO searchPhotoLabs(String keyword, Double latitude, Double longitude, Pageable pageable) {
129+
Page<PhotoLab> labs = postRepository.searchByName(keyword, pageable);
130+
131+
List<PostResponse.PhotoLabSearchDTO> dtos = labs.getContent().stream()
132+
.map(lab -> {
133+
String distanceStr = null;
134+
135+
if (latitude != null && longitude != null && lab.getLatitude() != null && lab.getLongitude() != null) {
136+
double distance = calculateDistance(
137+
latitude,
138+
longitude,
139+
lab.getLatitude().doubleValue(),
140+
lab.getLongitude().doubleValue()
141+
);
142+
distanceStr = String.format(DISTANCE_FORMAT_KM, distance);
143+
}
144+
145+
return PostResponse.PhotoLabSearchDTO.from(lab, distanceStr);
146+
})
147+
.toList();
148+
149+
return PostResponse.PhotoLabSearchListDTO.from(dtos);
150+
}
151+
152+
// 현상소 검색 직선 거리 계산
153+
private double calculateDistance(double lat1, double lon1, double lat2, double lon2) {
154+
double theta = lon1 - lon2;
155+
double dist = Math.sin(Math.toRadians(lat1)) * Math.sin(Math.toRadians(lat2))
156+
+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) * Math.cos(Math.toRadians(theta));
157+
dist = Math.acos(dist);
158+
dist = Math.toDegrees(dist);
159+
return dist * MINUTES_IN_DEGREE * STATUTE_MILES_PER_NAUTICAL_MILE * KILOMETERS_PER_STATUTE_MILE;
160+
}
93161
}

0 commit comments

Comments
 (0)