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 @@ -6,6 +6,7 @@
import ita.tinybite.domain.party.entity.Party;
import ita.tinybite.domain.party.entity.PartyParticipant;
import ita.tinybite.domain.party.enums.ParticipantStatus;
import ita.tinybite.domain.party.enums.PartyStatus;
import ita.tinybite.domain.user.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
Expand All @@ -24,4 +25,23 @@ public interface PartyParticipantRepository extends JpaRepository<PartyParticipa
List<PartyParticipant> findByPartyAndStatus(Party party, ParticipantStatus status);

boolean existsByPartyAndUserAndStatus(Party party, User user, ParticipantStatus status);

@Query("SELECT pp FROM PartyParticipant pp " +
"WHERE pp.user.id = :userId " +
"AND pp.party.status =:partyStatus " +
"AND pp.status = :participantStatus")
List<PartyParticipant> findActivePartiesByUserId(
@Param("userId") Long userId,
@Param("partyStatuses") PartyStatus partyStatus,
@Param("participantStatus") ParticipantStatus participantStatus
);
Comment on lines +29 to +37
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

# Search for User entity definition
fd -t f -e java "User.java" | head -20

Repository: tinybite-2025/tinybite-server

Length of output: 128


🏁 Script executed:

# Search for PartyParticipant entity definition
fd -t f -e java "PartyParticipant.java" | head -20

Repository: tinybite-2025/tinybite-server

Length of output: 141


🏁 Script executed:

# Also search in the repository file itself to understand the context better
cat -n src/main/java/ita/tinybite/domain/party/repository/PartyParticipantRepository.java

Repository: tinybite-2025/tinybite-server

Length of output: 2246


🏁 Script executed:

# Read User entity to check field name
cat -n src/main/java/ita/tinybite/domain/user/entity/User.java | head -50

Repository: tinybite-2025/tinybite-server

Length of output: 1653


🏁 Script executed:

# Read PartyParticipant entity
cat -n src/main/java/ita/tinybite/domain/party/entity/PartyParticipant.java | head -80

Repository: tinybite-2025/tinybite-server

Length of output: 2306


JPQL 파라미터 바인딩 및 필드명 불일치로 런타임 오류가 발생합니다.

  • Line 35: @Param("partyStatuses")는 복수형이지만 JPQL에서 :partyStatus(단수형)를 사용하여 바인딩이 실패합니다.
  • Line 30: User 엔티티의 ID 필드명은 userId이므로 JPQL에서 pp.user.id 대신 pp.user.userId를 사용해야 합니다.
  • Line 3: io.lettuce.core.dynamic.annotation.Param을 import하고 있는데, Spring Data JPA 쿼리에는 org.springframework.data.jpa.repository.Param을 사용해야 합니다.
🔎 수정 제안
-import io.lettuce.core.dynamic.annotation.Param;
+import org.springframework.data.jpa.repository.Param;

 @Query("SELECT pp FROM PartyParticipant pp " +
-        "WHERE pp.user.id = :userId " +
+        "WHERE pp.user.userId = :userId " +
         "AND pp.party.status =:partyStatus " +
         "AND pp.status = :participantStatus")
 List<PartyParticipant> findActivePartiesByUserId(
         @Param("userId") Long userId,
-        @Param("partyStatuses") PartyStatus partyStatus,
+        @Param("partyStatus") PartyStatus partyStatus,
         @Param("participantStatus") ParticipantStatus participantStatus
 );
🤖 Prompt for AI Agents
In
src/main/java/ita/tinybite/domain/party/repository/PartyParticipantRepository.java
around lines 29 to 37, the JPQL and parameter annotations are mismatched and the
wrong @Param import is used; update the query to reference the correct user id
field (replace pp.user.id with pp.user.userId), make the @Param name for the
party status match the JPQL placeholder (use "partyStatus" instead of
"partyStatuses") and ensure the method parameter name matches that @Param, and
replace the imported io.lettuce.core.dynamic.annotation.Param with
org.springframework.data.jpa.repository.Param so Spring Data JPA binds
parameters correctly.


@Query("SELECT COUNT(pp) FROM PartyParticipant pp " +
"WHERE pp.party.id = :partyId " +
"AND pp.status = :status")
int countByPartyIdAndStatus(
@Param("partyId") Long partyId,
@Param("status") ParticipantStatus status
);

}
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
package ita.tinybite.domain.user.controller;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import ita.tinybite.domain.auth.dto.response.UserDto;
import ita.tinybite.domain.user.dto.req.UpdateUserReqDto;
import ita.tinybite.domain.user.dto.res.PartyResponse;
import ita.tinybite.domain.user.dto.res.UserResDto;
import ita.tinybite.domain.user.service.UserService;
import ita.tinybite.global.response.APIResponse;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

import java.util.List;

import static ita.tinybite.global.response.APIResponse.success;

@RestController
Expand All @@ -18,30 +31,73 @@ public UserController(UserService userService) {
this.userService = userService;
}

@Operation(summary = "내 정보 조회", description = "현재 로그인한 사용자의 정보를 조회합니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "조회 성공",
content = @Content(schema = @Schema(implementation = UserResDto.class))),
@ApiResponse(responseCode = "401", description = "인증 실패",
content = @Content(schema = @Schema(implementation = APIResponse.class)))
})
@GetMapping("/me")
public APIResponse<?> getUser() {
public APIResponse<UserResDto> getUser() {
return success(userService.getUser());
}

@Operation(summary = "내 정보 수정", description = "현재 로그인한 사용자의 정보를 수정합니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "수정 성공"),
@ApiResponse(responseCode = "400", description = "잘못된 요청",
content = @Content(schema = @Schema(implementation = APIResponse.class))),
@ApiResponse(responseCode = "401", description = "인증 실패")
})
@PatchMapping("/me")
public APIResponse<?> updateUser(@Valid @RequestBody UpdateUserReqDto req) {
userService.updateUser(req);
return success();
}

@Operation(summary = "위치 정보 수정", description = "사용자의 현재 위치(위도, 경도)를 업데이트합니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "위치 업데이트 성공"),
@ApiResponse(responseCode = "401", description = "인증 실패")
})
@PatchMapping("/me/location")
public APIResponse<?> updateLocation(@RequestParam(defaultValue = "37.3623504988728") String latitude,
@RequestParam(defaultValue = "127.117057453619") String longitude) {
userService.updateLocation(latitude, longitude);
return success();
}

@Operation(summary = "회원 탈퇴", description = "현재 로그인한 사용자를 삭제합니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "탈퇴 성공"),
@ApiResponse(responseCode = "401", description = "인증 실패")
})
@DeleteMapping("/me")
public APIResponse<?> deleteUser() {
userService.deleteUser();
return success();
}

@Operation(summary = "활성 파티 목록 조회", description = "사용자가 참여 중인 활성 파티 목록을 조회합니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "조회 성공",
content = @Content(array = @ArraySchema(schema = @Schema(implementation = PartyResponse.class)))),
@ApiResponse(responseCode = "401", description = "인증 실패")
})
@GetMapping("/parties/active")
public ResponseEntity<List<PartyResponse>> getActiveParties(
@AuthenticationPrincipal Long userId) {
List<PartyResponse> response = userService.getActiveParties(userId);
return ResponseEntity.ok(response);
}

@Operation(summary = "닉네임 중복 확인", description = "닉네임 사용 가능 여부를 확인합니다.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "사용 가능한 닉네임"),
@ApiResponse(responseCode = "400", description = "이미 사용 중인 닉네임",
content = @Content(schema = @Schema(implementation = APIResponse.class)))
})
@GetMapping("/nickname/check")
public APIResponse<?> validateNickname(@RequestParam String nickname) {
userService.validateNickname(nickname);
Expand Down
45 changes: 45 additions & 0 deletions src/main/java/ita/tinybite/domain/user/dto/res/PartyResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package ita.tinybite.domain.user.dto.res;

import ita.tinybite.domain.party.entity.Party;
import ita.tinybite.domain.party.enums.ParticipantStatus;
import ita.tinybite.domain.party.enums.PartyStatus;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;

import java.time.LocalDateTime;

@Getter
@AllArgsConstructor
@Builder
public class PartyResponse {
private Long id;
private String title;
private String description;
private Integer maxParticipants;
private Integer currentParticipants;
private PartyStatus status;
private String hostUsername;
private LocalDateTime startDate;
private LocalDateTime endDate;
private LocalDateTime createdAt;
private boolean isHost;
private ParticipantStatus participantStatus;

public static PartyResponse from(Party party, int currentParticipants, boolean isHost, ParticipantStatus participantStatus) {
return PartyResponse.builder()
.id(party.getId())
.title(party.getTitle())
.description(party.getDescription())
.maxParticipants(party.getMaxParticipants())
.currentParticipants(currentParticipants)
.status(party.getStatus())
.hostUsername(party.getHost().getNickname())
.startDate(party.getCreatedAt())
.endDate(party.getClosedAt())
.createdAt(party.getCreatedAt())
Comment on lines +37 to +40
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, locate and read the PartyResponse.java file
find . -name "PartyResponse.java" -type f

Repository: tinybite-2025/tinybite-server

Length of output: 140


🏁 Script executed:

# Also locate the Party entity
find . -name "Party.java" -type f

Repository: tinybite-2025/tinybite-server

Length of output: 132


🏁 Script executed:

# Check file sizes before reading
wc -l ./src/main/java/ita/tinybite/domain/user/dto/res/PartyResponse.java
wc -l ./src/main/java/ita/tinybite/domain/party/entity/Party.java

Repository: tinybite-2025/tinybite-server

Length of output: 207


🏁 Script executed:

# Read PartyResponse.java entirely
cat -n ./src/main/java/ita/tinybite/domain/user/dto/res/PartyResponse.java

Repository: tinybite-2025/tinybite-server

Length of output: 1960


🏁 Script executed:

# Read Party.java to check fields
cat -n ./src/main/java/ita/tinybite/domain/party/entity/Party.java

Repository: tinybite-2025/tinybite-server

Length of output: 6650


🏁 Script executed:

# Check if there are any null safety patterns for host field
rg "host.*null|host.*Optional" ./src/main/java/ita/tinybite/domain/party/entity/Party.java ./src/main/java/ita/tinybite/domain/user/dto/res/PartyResponse.java

Repository: tinybite-2025/tinybite-server

Length of output: 184


🏁 Script executed:

# Search for other usages of party.getHost() to see if null checks are done elsewhere
rg "getHost\(\)" --type java -A 2 -B 2 | head -50

Repository: tinybite-2025/tinybite-server

Length of output: 4923


startDatecreatedAt이 동일한 값으로 매핑되고 있으며, NPE 방지를 위한 개선이 필요합니다.

  1. startDatecreatedAt이 모두 party.getCreatedAt()으로 설정되어 있어 중복되고 의미가 불명확합니다. Party 엔티티에는 별도의 시작일(startDate) 필드가 없으므로, startDate의 의도를 명확히 하고 적절한 필드를 매핑해야 합니다. 현재 매핑은 파티 생성 시간을 두 필드에 중복으로 설정하는 것으로 보이며, 이는 DTO 설계를 재검토할 필요가 있습니다.
  2. party.getHost()가 null이 될 가능성에 대비하여 null 체크를 추가하는 것을 권장합니다.
🔎 NPE 방지를 위한 수정 제안
-                .hostUsername(party.getHost().getNickname())
+                .hostUsername(party.getHost() != null ? party.getHost().getNickname() : null)
🤖 Prompt for AI Agents
In src/main/java/ita/tinybite/domain/user/dto/res/PartyResponse.java around
lines 37 to 40, startDate is incorrectly mapped to party.getCreatedAt()
(duplicating createdAt) and party.getHost() is used without a null check; change
the startDate mapping to the correct source (e.g., party.getStartDate() if the
Party entity has that field, otherwise leave startDate null or remove the
duplicate), and guard host access with a null check so the DTO uses host == null
? null : host.getNickname() to avoid NPEs.

.isHost(isHost)
.participantStatus(participantStatus)
.build();
}
}
33 changes: 32 additions & 1 deletion src/main/java/ita/tinybite/domain/user/service/UserService.java
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
package ita.tinybite.domain.user.service;

import ita.tinybite.domain.auth.service.SecurityProvider;
import ita.tinybite.domain.party.entity.Party;
import ita.tinybite.domain.party.entity.PartyParticipant;
import ita.tinybite.domain.party.enums.ParticipantStatus;
import ita.tinybite.domain.party.enums.PartyStatus;
import ita.tinybite.domain.party.repository.PartyParticipantRepository;
import ita.tinybite.domain.user.constant.UserStatus;
import ita.tinybite.domain.user.dto.req.UpdateUserReqDto;
import ita.tinybite.domain.user.dto.res.PartyResponse;
import ita.tinybite.domain.user.dto.res.UserResDto;
import ita.tinybite.domain.user.entity.User;
import ita.tinybite.domain.user.repository.UserRepository;
Expand All @@ -11,19 +17,25 @@
import ita.tinybite.global.location.LocationService;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.stream.Collectors;

@Service
public class UserService {

private final SecurityProvider securityProvider;
private final UserRepository userRepository;
private final LocationService locationService;
private final PartyParticipantRepository participantRepository;

public UserService(SecurityProvider securityProvider,
UserRepository userRepository,
LocationService locationService) {
LocationService locationService,
PartyParticipantRepository participantRepository) {
this.securityProvider = securityProvider;
this.userRepository = userRepository;
this.locationService = locationService;
this.participantRepository = participantRepository;
}

public UserResDto getUser() {
Expand All @@ -50,4 +62,23 @@ public void validateNickname(String nickname) {
if(userRepository.existsByNickname(nickname))
throw BusinessException.of(AuthErrorCode.DUPLICATED_NICKNAME);
}

public List<PartyResponse> getActiveParties(Long userId) {
List<PartyParticipant> participants = participantRepository
.findActivePartiesByUserId(
userId,
PartyStatus.RECRUITING,
ParticipantStatus.APPROVED
);

return participants.stream()
.map(pp -> {
Party party = pp.getParty();
int currentParticipants = participantRepository
.countByPartyIdAndStatus(party.getId(), ParticipantStatus.APPROVED);
boolean isHost = party.getHost().getUserId().equals(userId);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

party.getHost()가 null일 경우 NPE가 발생할 수 있습니다.

호스트가 탈퇴하거나 삭제된 경우 getHost()가 null을 반환할 수 있습니다.

🔎 수정 제안
-                    boolean isHost = party.getHost().getUserId().equals(userId);
+                    boolean isHost = party.getHost() != null && party.getHost().getUserId().equals(userId);
🤖 Prompt for AI Agents
In src/main/java/ita/tinybite/domain/user/service/UserService.java around line
79, calling party.getHost().getUserId() can throw an NPE if getHost() is null;
change the logic to perform a null-safe check (e.g., assign Host host =
party.getHost(); then set isHost = host != null &&
Objects.equals(host.getUserId(), userId) or use
Optional.ofNullable(party.getHost()).map(Host::getUserId).map(id ->
id.equals(userId)).orElse(false)) so that when the host is missing isHost
becomes false and no NPE is raised.

return PartyResponse.from(party, currentParticipants, isHost,pp.getStatus());
})
.collect(Collectors.toList());
Comment on lines +74 to +82
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

N+1 쿼리 문제가 있습니다.

PartyParticipant마다 countByPartyIdAndStatus()를 호출하여 참여자 수를 조회하고 있습니다. 사용자가 N개의 활성 파티에 참여 중이라면 N+1개의 쿼리가 발생합니다. 파티 수가 많아지면 성능 저하가 발생할 수 있습니다.

🔎 개선 방안

하나의 쿼리로 파티별 참여자 수를 미리 조회하는 방법을 고려해보세요:

// Repository에 추가
@Query("SELECT pp.party.id, COUNT(pp) FROM PartyParticipant pp " +
       "WHERE pp.party.id IN :partyIds AND pp.status = :status " +
       "GROUP BY pp.party.id")
List<Object[]> countByPartyIdsAndStatus(
    @Param("partyIds") List<Long> partyIds,
    @Param("status") ParticipantStatus status
);

또는 Party 엔티티의 currentParticipants 필드가 이미 동기화되어 있다면 해당 필드를 사용할 수 있습니다.

🤖 Prompt for AI Agents
In src/main/java/ita/tinybite/domain/user/service/UserService.java around lines
74-82, the current implementation issues N+1 queries by calling
participantRepository.countByPartyIdAndStatus(...) per PartyParticipant;
instead, add a repository method that returns counts grouped by party IDs for
the list of party IDs (e.g., countByPartyIdsAndStatus), call that once with all
party IDs to produce a Map<Long,Integer> of partyId->count, and then use that
map and the already-loaded Party/host info when mapping participants to
PartyResponse; ensure you collect partyIds from participants first, invoke the
batch count query, build the lookup, and replace per-item count calls with
lookups to eliminate the per-item DB call.

}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package ita.tinybite.domain.user.service;

import ita.tinybite.domain.auth.service.AuthService;
import ita.tinybite.domain.party.entity.Party;
import ita.tinybite.domain.party.repository.PartyParticipantRepository;
import ita.tinybite.domain.user.constant.LoginType;
import ita.tinybite.domain.user.constant.UserStatus;
import ita.tinybite.domain.user.dto.req.UpdateUserReqDto;
Expand All @@ -23,6 +25,8 @@ class UserServiceTest {
@Autowired
private UserRepository userRepository;

private PartyParticipantRepository participantRepository;

Comment on lines +28 to +29
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

participantRepository가 초기화되지 않아 NPE가 발생할 수 있습니다.

participantRepository 필드가 @Autowired@Mock으로 선언되지 않아 null 상태로 UserService에 전달됩니다. 현재 테스트에서 getActiveParties() 메서드를 호출하지 않더라도, 새로 추가된 기능에 대한 테스트 커버리지가 누락되어 있고 향후 NPE를 유발할 수 있습니다.

🔎 수정 제안
     @Autowired
     private UserRepository userRepository;
 
-    private PartyParticipantRepository participantRepository;
+    @Autowired
+    private PartyParticipantRepository participantRepository;

또는 getActiveParties() 메서드에 대한 별도의 테스트 케이스를 추가하는 것을 권장합니다.

Also applies to: 44-44

@Autowired
private AuthService authService;

Expand All @@ -37,7 +41,7 @@ class UserServiceTest {
void setUp() {
securityProvider = new FakeSecurityProvider(userRepository);
locationService = new FakeLocationService();
userService = new UserService(securityProvider, userRepository, locationService);
userService = new UserService(securityProvider, userRepository, locationService,participantRepository);

User user = User.builder()
.email("yyytir777@gmail.com")
Expand Down