Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
e9022b1
[refactor] 장소 리스트 요약 정보를 저장하기 위한 UserPlace 엔티티 로직 개선
seung-in-Yoo Aug 13, 2025
a547bc6
[refactor] 웨이블존 매핑 레포지토리에 쿼리,메서드 추가
seung-in-Yoo Aug 13, 2025
b02143d
[refactor] 사용자별 리스트 조회 정렬 메서드 추가
seung-in-Yoo Aug 13, 2025
472715d
[feat] 웨이블존 리스트 요약 관련 Dto 생성
seung-in-Yoo Aug 13, 2025
d83625c
[feat] 특정 리스트 내부의 웨이블존 목록을 위한 Dto 생성
seung-in-Yoo Aug 13, 2025
e1d0611
[feat] 유저 장소 요청 관련 Dto에 color 필드 추가
seung-in-Yoo Aug 13, 2025
bd998c7
[refactor] 웨이블존 매핑 레포지토리 로직 수정
seung-in-Yoo Aug 13, 2025
4ef97f0
[refactor] 소유권 검증 및 단건 조회 관련 로직 추가
seung-in-Yoo Aug 13, 2025
4ca773c
[refactor] 유저 장소 관련 에러 케이스 추가
seung-in-Yoo Aug 13, 2025
fc54965
[refactor] 유저 웨이블존 장소 관련 레포지토리,서비스 로직 리팩토링 진행
seung-in-Yoo Aug 13, 2025
bc4e2fc
[refactor] 좋아요 감소 시 음수 방지 코드 추가
seung-in-Yoo Aug 13, 2025
a21f386
[refactor] 웨이블존 color 기본색 회색으로 설정
seung-in-Yoo Aug 13, 2025
9ba53f5
[feat] 삭제용 요청 Dto 추가
seung-in-Yoo Aug 13, 2025
22e27a9
[refactor] 유저 장소 관련 컨트롤러 로직 리팩토링
seung-in-Yoo Aug 13, 2025
479c238
Merge branch 'develop' of https://github.com/Wayble-Project/wayble-sp…
seung-in-Yoo Aug 15, 2025
be3014f
[refactor] 토큰 인증 필수로 변경
seung-in-Yoo Aug 15, 2025
aed685b
[refactor] 요청 DTO에서 userId 제거
seung-in-Yoo Aug 15, 2025
a57f7ec
[refactor] 리뷰 서비스 로직 userId 관련 개선
seung-in-Yoo Aug 15, 2025
595590f
[refactor] 토큰에서 userId 추출로 변경 (요청 Dto userId 삭제)
seung-in-Yoo Aug 15, 2025
35e49b0
[refactor] 사용하지 않는 userId 제거
seung-in-Yoo Aug 15, 2025
8e11d3c
[refactor] 페이지 인덱스 1‑ 기반 입력을 0‑ 기반으로 변환
seung-in-Yoo Aug 15, 2025
c87b8a3
[fix] 페이징에서 1페이지일때 서버에서는 0페이지에서 처리하도록 로직 수정 및 사용하지 않는 로직 제거
seung-in-Yoo Aug 15, 2025
21f0cdc
[refactor] 사용하지 않는 import 제거
seung-in-Yoo Aug 15, 2025
9c73722
[refactor] 카운트쿼리 로직 추가
seung-in-Yoo Aug 15, 2025
bbb4827
[refactor] 컨트롤러 로직 분리 (내 장소 리스트 요약 조회, 특정 장소 내 웨이블존 목록 조회(페이징)
seung-in-Yoo Aug 15, 2025
c45ad40
[refactor] 코드리뷰 반영
seung-in-Yoo Aug 16, 2025
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 @@ -10,8 +10,9 @@
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Pattern;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;


Expand All @@ -37,12 +38,10 @@ public class ReviewController {
})
public CommonResponse<String> registerReview(
@PathVariable Long waybleZoneId,
@RequestBody @Valid ReviewRegisterDto dto,

// TODO: 로그인 구현 후 Authorization 헤더 필수로 변경 필요
@RequestHeader(value = "Authorization", required = false) String authorizationHeader
@RequestBody @Valid ReviewRegisterDto dto
) {
reviewService.registerReview(waybleZoneId, dto, authorizationHeader);
Long userId = extractUserId(); // 토큰에서 유저 ID 추출
reviewService.registerReview(waybleZoneId, userId, dto);
return CommonResponse.success("리뷰가 등록되었습니다.");
}

Expand All @@ -58,4 +57,26 @@ public CommonResponse<List<ReviewResponseDto>> getReviews(
) {
return CommonResponse.success(reviewService.getReviews(waybleZoneId, sort));
}

private Long extractUserId() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

토큰에서 id 추출하는 과정을 이렇게 상세하게 구현한 이유가 궁금합니다...!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

밑에 답변 참고해주세요!

Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null) { throw new IllegalStateException("인증 정보가 없습니다."); }

Object p = auth.getPrincipal();
if (p instanceof Long l) { return l; }
if (p instanceof Integer i) { return i.longValue(); }
if (p instanceof String s) {
try {
return Long.parseLong(s);
} catch (NumberFormatException e) {
throw new IllegalStateException("principal에서 userId 파싱 실패");
}
}
try {
return Long.parseLong(auth.getName());
}
catch (Exception e) {
throw new IllegalStateException("인증 정보에서 userId를 추출할 수 없습니다.");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,6 @@

@Schema(description = "리뷰 등록 요청 DTO")
public record ReviewRegisterDto(

@Schema(description = "작성자 ID", example = "1")
@NotNull(message = "작성자 ID는 필수입니다.")
Long userId,

@Schema(description = "리뷰 내용", example = "뷰가 좋고 접근성이 좋은 카페예요.")
@NotBlank(message = "리뷰 내용은 비어 있을 수 없습니다.")
String content,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,11 @@ public class ReviewService {
private final UserRepository userRepository;

@Transactional
public void registerReview(Long zoneId, ReviewRegisterDto dto, String token) {
public void registerReview(Long zoneId, Long userId, ReviewRegisterDto dto) {
WaybleZone zone = waybleZoneRepository.findById(zoneId)
.orElseThrow(() -> new ApplicationException(WaybleZoneErrorCase.WAYBLE_ZONE_NOT_FOUND));

User user = userRepository.findById(dto.userId())
User user = userRepository.findById(userId)
.orElseThrow(() -> new ApplicationException(UserErrorCase.USER_NOT_FOUND));

Review review = Review.of(user, zone, dto.content(), dto.rating());
Expand All @@ -44,14 +44,13 @@ public void registerReview(Long zoneId, ReviewRegisterDto dto, String token) {
zone.updateRating(newRating);
zone.addReviewCount(1);

if (dto.images() != null) {
if (dto.images() != null && !dto.images().isEmpty()) {
for (String imageUrl : dto.images()) {
reviewImageRepository.save(ReviewImage.of(review, imageUrl));
}
}
waybleZoneRepository.save(zone);

// visitDate 및 facilities 저장은 필요시 추가 구현
waybleZoneRepository.save(zone);
}

@Transactional(readOnly = true)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
package com.wayble.server.user.controller;

import com.wayble.server.common.exception.ApplicationException;

import com.wayble.server.common.response.CommonResponse;
import com.wayble.server.user.dto.UserPlaceListResponseDto;
import com.wayble.server.user.dto.UserPlaceRemoveRequestDto;
import com.wayble.server.user.dto.UserPlaceRequestDto;
import com.wayble.server.user.exception.UserErrorCase;
import com.wayble.server.user.dto.UserPlaceSummaryDto;
import com.wayble.server.user.service.UserPlaceService;
import com.wayble.server.wayblezone.dto.WaybleZoneListResponseDto;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
Expand Down Expand Up @@ -41,19 +43,80 @@ public CommonResponse<String> saveUserPlace(
}

@GetMapping
@Operation(summary = "내 장소 리스트 요약 조회", description = "장소 관련 목록(리스트)만 반환합니다(개수 포함).")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "조회 성공"),
@ApiResponse(responseCode = "404", description = "유저를 찾을 수 없음"),
@ApiResponse(responseCode = "403", description = "권한이 없습니다.")
})
public CommonResponse<List<UserPlaceSummaryDto>> getMyPlaceSummaries(
@RequestParam(name = "sort", defaultValue = "latest") String sort
) {
Long userId = extractUserId();
List<UserPlaceSummaryDto> summaries = userPlaceService.getMyPlaceSummaries(userId, sort);
return CommonResponse.success(summaries);
}


@GetMapping("/zones")
@Operation(summary = "특정 장소 내 웨이블존 목록 조회(페이징)",
description = "placeId로 해당 장소 내부의 웨이블존 카드 목록을 반환합니다. (page는 1부터 시작.)")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "조회 성공"),
@ApiResponse(responseCode = "404", description = "유저/장소를 찾을 수 없음"),
@ApiResponse(responseCode = "403", description = "권한이 없습니다.")
})
public CommonResponse<Page<WaybleZoneListResponseDto>> getZonesInPlace(
@RequestParam Long placeId,
@RequestParam(defaultValue = "1") Integer page,
@RequestParam(defaultValue = "20") Integer size
) {
Long userId = extractUserId();
Page<WaybleZoneListResponseDto> zones = userPlaceService.getZonesInPlace(userId, placeId, page, size);
return CommonResponse.success(zones);
}

@DeleteMapping
@Operation(
summary = "내가 저장한 장소 목록 조회",
description = "유저가 저장한 모든 장소 및 해당 웨이블존 정보를 조회합니다."
summary = "장소에서 웨이블존 제거",
description = "RequestBody로 placeId, waybleZoneId를 받아 지정한 장소에서 웨이블존을 제거합니다."
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "장소 목록 조회 성공"),
@ApiResponse(responseCode = "404", description = "해당 유저를 찾을 수 없음"),
@ApiResponse(responseCode = "200", description = "제거 성공"),
@ApiResponse(responseCode = "404", description = "장소 또는 매핑 정보를 찾을 수 없음"),
@ApiResponse(responseCode = "403", description = "권한이 없습니다.")
})
public CommonResponse<List<UserPlaceListResponseDto>> getUserPlaces(
) {
Long userId = (Long) SecurityContextHolder.getContext().getAuthentication().getPrincipal();
List<UserPlaceListResponseDto> places = userPlaceService.getUserPlaces(userId);
return CommonResponse.success(places);
public CommonResponse<String> removeZoneFromPlace(@RequestBody @Valid UserPlaceRemoveRequestDto request) {
Long userId = extractUserId();
userPlaceService.removeZoneFromPlace(userId, request.placeId(), request.waybleZoneId());
return CommonResponse.success("제거되었습니다.");
}


// SecurityContext에서 userId 추출하는 로직
private Long extractUserId() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ReviewController랑 여기에 모두 동일한 로직을 넣은 이유도 궁금합니다!
토큰 id 정책이 바뀌면 모든 컨트롤러의 로직을 변경하게 될텐데, 차라리 id에서 토큰을 검증하는 서비스 하나 만들거나, jwtTokenService 같은 곳에서 처리하는게 낫겠다는 생각이 들어서요!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

지금 작성한 pr에 대한 코드를 짤때 생각보다 api 자체를 많이 바꿔야해서 개발 후에 테스트에 좀 더 신경을 썼었는데, 제가 테스트를 여러번 할때에 Authentication.getPrincipal() 타입이 일관되게 들어오지 않는것 같더라구요. 그렇게된다면 컨트롤러에서 에러가 날수도 있다고 생각해서 아예 컨트롤러쪽에 토큰을 추출하는 메서드를 추가하였습니다. 하지만 말씀해주신것처럼 토큰 정책이 바뀌면 컨트롤러마다 수정해야 하는 리스크가 있다는것에 동의합니다! 중복 제거 및 공통화에 대해서 리팩토링 해보겠습니다 감사합니다 ㅎㅎ

Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null) {
throw new IllegalStateException("인증 정보가 없습니다.");
}

Object p = auth.getPrincipal();

if (p instanceof Long l) { return l; }
if (p instanceof Integer i) { return i.longValue(); }
if (p instanceof String s) {
try {
return Long.parseLong(s);
} catch (NumberFormatException e) {
// 숫자 변환 실패 시 출력
System.err.println("Principal 문자열을 Long으로 변환할 수 없습니다: " + s);
}
}

try {
return Long.parseLong(auth.getName());
} catch (Exception e) {
throw new IllegalStateException("인증 정보에서 userId를 추출할 수 없습니다. Principal=" + p, e);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.wayble.server.user.dto;

import jakarta.validation.constraints.NotNull;

public record UserPlaceRemoveRequestDto(
@NotNull Long placeId,
@NotNull Long waybleZoneId
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@

public record UserPlaceRequestDto(
@NotNull Long waybleZoneId,
@NotNull String title
@NotNull String title,
String color
) {}
11 changes: 11 additions & 0 deletions src/main/java/com/wayble/server/user/dto/UserPlaceSummaryDto.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.wayble.server.user.dto;

import lombok.Builder;

@Builder
public record UserPlaceSummaryDto(
Long placeId,
String title,
String color,
int savedCount
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.wayble.server.user.dto;

import com.wayble.server.wayblezone.dto.WaybleZoneListResponseDto;
import lombok.Builder;

import java.util.List;

@Builder
public record UserPlaceZonesResponseDto(
Long placeId,
String title,
String color,
int savedCount,
List<WaybleZoneListResponseDto> zones
) {}
18 changes: 18 additions & 0 deletions src/main/java/com/wayble/server/user/entity/UserPlace.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,17 @@
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;


@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@SQLDelete(sql = "UPDATE user_place SET deleted_at = NOW() WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")
@Table(name = "user_place") // 유져가 저장한 장소
public class UserPlace extends BaseEntity {

Expand All @@ -23,7 +27,21 @@ public class UserPlace extends BaseEntity {
@Column(name = "title", nullable = false)
private String title;

@Column(length = 20) @Builder.Default
private String color = "GRAY"; // 배지/아이콘 색 (정확히 무슨 색이 있는지 몰라서 일단 자유 문자열 + 기본: 회색)

@Column(name = "saved_count", nullable = false, columnDefinition = "int default 0")
@Builder.Default
private int savedCount = 0; // 리스트에 담긴 웨이블존 수


@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private User user;

public void increaseCount() { this.savedCount++; }
public void decreaseCount() { if (this.savedCount > 0) this.savedCount--; }

public void updateTitle(String title) { this.title = title; }
public void updateColor(String color) { this.color = color; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ public enum UserErrorCase implements ErrorCase {
INVALID_BIRTH_DATE(400, 1010, "생년월일 형식이 올바르지 않습니다."),
USER_INFO_NOT_EXISTS(404,1011, "유저 정보가 존재하지 않습니다."),
NICKNAME_REQUIRED(400, 1012,"nickname 파라미터는 필수입니다."),
NICKNAME_DUPLICATED(409,1013, "이미 사용 중인 닉네임입니다.");
NICKNAME_DUPLICATED(409,1013, "이미 사용 중인 닉네임입니다."),
PLACE_NOT_FOUND(404, 1014, "저장된 장소를 찾을 수 없습니다."),
PLACE_MAPPING_NOT_FOUND(404, 1015, "해당 장소에 해당 웨이블존이 없습니다.");

private final Integer httpStatusCode;
private final Integer errorCode;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@
import com.wayble.server.user.entity.UserPlace;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;
import java.util.Optional;

public interface UserPlaceRepository extends JpaRepository<UserPlace, Long> {
Optional<UserPlace> findByUser_IdAndTitle(Long userId, String title);
List<UserPlace> findAllByUser_IdOrderByCreatedAtDesc(Long userId); // 사용자 리스트 전체(최신순)
Optional<UserPlace> findByIdAndUser_Id(Long placeId, Long userId);
List<UserPlace> findAllByUser_IdOrderByTitleAsc(Long userId);
Comment on lines +11 to +13
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

소프트 삭제(soft-delete) 행 필터링 여부 확인 필요

Admin 쪽에서 user_place.deleted_at을 사용하는 복구 쿼리가 존재합니다. 현재 파생 메서드들은 deleted_at IS NULL 조건을 자동으로 걸지 않으므로, 소프트 삭제된 행이 포함될 수 있습니다. 엔티티에 @SQLRestriction("deleted_at IS NULL")가 없거나 글로벌 필터가 없다면, 사용자 리스트/조회에 삭제된 레코드가 섞일 수 있습니다.

다음 스크립트로 UserPlace 엔티티에 @SQLDelete/@SQLRestriction이 적용되어 있는지 확인해 주세요.

필요 시, UserPlace 엔티티에 아래와 같이 추가하는 것을 권장합니다(참고용):

@SQLDelete(sql = "UPDATE user_place SET deleted_at = now() WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")

🏁 Script executed:

#!/bin/bash
# UserPlace 소프트삭제 설정 존재 여부 탐색
rg -n -C2 'class\s+UserPlace\b' --type=java
rg -n -C2 '@SQLDelete|@SQLRestriction' --type=java | rg -n -C1 'UserPlace|user_place'

Length of output: 557


소프트 삭제 필터링 적용 필요: UserPlace 엔티티에 @SQLDelete/@SQLRestriction 추가

현재 UserPlace 엔티티에 소프트 삭제 관련 어노테이션이 없어, deleted_at IS NULL 조건이 자동으로 붙지 않습니다. 이로 인해 findAllByUser_Id… 계열 메서드 호출 시 삭제된 레코드가 조회될 수 있습니다.

수정 사항:

  • 아래 어노테이션을 src/main/java/com/wayble/server/user/entity/UserPlace.java 클래스 선언부 바로 위에 추가해주세요.
@SQLDelete(sql = "UPDATE user_place SET deleted_at = NOW() WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")
@Table(name = "user_place")  // 기존 어노테이션
public class UserPlace extends BaseEntity {
    // ...
}

위 설정으로 Hibernate가 소프트 삭제 시 deleted_at을 업데이트하고, 조회 시 자동으로 deleted_at IS NULL 필터를 적용합니다.

🤖 Prompt for AI Agents
In src/main/java/com/wayble/server/user/entity/UserPlace.java around the class
declaration, UserPlace lacks Hibernate soft-delete annotations so deleted
records are still returned by repository queries; add the @SQLDelete(sql =
"UPDATE user_place SET deleted_at = NOW() WHERE id = ?") and
@SQLRestriction("deleted_at IS NULL") annotations immediately above the class
(retaining the existing @Table) and add the necessary imports from
org.hibernate.annotations; ensure the entity has the deleted_at column mapped
(or inherited) and that imports compile so Hibernate will set deleted_at on
delete and automatically filter out soft-deleted rows during queries.

}
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
package com.wayble.server.user.repository;

import com.wayble.server.user.entity.UserPlaceWaybleZoneMapping;
import com.wayble.server.wayblezone.entity.WaybleZone;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;

Expand All @@ -11,4 +16,28 @@ public interface UserPlaceWaybleZoneMappingRepository extends JpaRepository<User

@EntityGraph(attributePaths = {"userPlace", "waybleZone"})
List<UserPlaceWaybleZoneMapping> findAllByUserPlace_User_Id(Long userId);
boolean existsByUserPlace_IdAndWaybleZone_Id(Long placeId, Long zoneId);
void deleteByUserPlace_IdAndWaybleZone_Id(Long placeId, Long zoneId);

// 리스트 내부 웨이블존 조회 (페이징 포함)
@Query(
value = """
select m.waybleZone
from UserPlaceWaybleZoneMapping m
where m.userPlace.id = :placeId
order by m.id desc
""",
countQuery = """
select count(m)
from UserPlaceWaybleZoneMapping m
where m.userPlace.id = :placeId
"""
)
Page<WaybleZone> findZonesByPlaceId(@Param("placeId") Long placeId, Pageable pageable);






}
Loading