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
44 changes: 42 additions & 2 deletions .coderabbit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,8 @@ reviews:
- Indentation: use spaces (no tabs), tab width = 4 spaces; files must end with Unix LF newline. (Team adaptation)
- Maximum line length: 120 characters.
- Imports: single-class imports only; allow wildcard for static imports; group imports with blank lines between sections.
- Assignment operators (`=`, `+=`, `-=`, etc.): always one space before and after; on line breaks, place the operator at the start of the next line.
- Operators: always one space before and after; on line breaks, place operators at the start of the next line (commas stay at end of line, dots at start of new line).
- Lambda expressions: omit parentheses for a single parameter; surround `->` with spaces (`param -> expression`); use braces and explicit `return` for multi-statement bodies; choose short, clear parameter names.
- In multi-line expressions, place operators and ternary separators at end-of-line.
- Prefer Java 21 standard APIs over Guava.
- Do not annotate immutable local variables with `final` unless required for an inner class.
- Allow the `var` keyword when the value is a cast `null`.
Expand All @@ -90,6 +89,47 @@ reviews:
- Prioritize correctness of assertions, coverage of edge cases, and clear test naming/documentation.
- Be lenient on style and minor optimizations in tests.

- path: "**/application/usecase/**/*.java"
instructions: |
- Use cases must be defined as interfaces with a single public method representing one business operation.
- Method names should clearly express the business intent (e.g., `create`, `delete`, `accept`, `send`).
- Avoid multiple unrelated operations in a single use case interface.
- Keep method signatures simple and focused; use Command or Query objects for complex parameters.

- path: "**/application/command/**/*.java"
instructions: |
- Commands must be immutable records.
- All input validation must occur in the compact constructor.
- Normalize inputs (e.g., `strip()` for strings) in the compact constructor before validation.
- Provide sensible defaults for optional fields (e.g., `Set.of()` for empty collections).
- Throw `BusinessException` with appropriate `ErrorCode` for validation failures.
- Commands should represent write operations; avoid query-related fields unless necessary for the write operation.

- path: "**/application/query/**/*.java"
instructions: |
- Queries must be immutable records.
- All input validation must occur in the compact constructor.
- Validate pagination parameters (e.g., `size > 0`, `size <= 100`, `lastId > 0`).
- Throw `BusinessException` with appropriate `ErrorCode` for validation failures.
- Queries should only contain parameters needed for read operations.

- path: "**/application/result/**/*.java"
instructions: |
- Results must be immutable records.
- Use static factory methods named `from` to convert from domain or query results.
- Results should only expose data needed by the presentation layer; avoid leaking domain internals.
- Prefer composition over deeply nested structures.

- path: "**/application/service/**/*Service.java"
instructions: |
- Services must be annotated with `@Service` and implement one or more use case interfaces.
- Follow the Read/Write separation pattern: use separate classes for read operations (e.g., `FriendReadService`) and write operations (e.g., `FriendWriteService`).
- Write operations must be annotated with `@Transactional`.
- Read operations should use `@Transactional(readOnly = true)` when appropriate.
- Services should orchestrate domain logic, not contain it; delegate business rules to domain entities.
- Throw `BusinessException` with appropriate `ErrorCode` for business rule violations.
- Avoid direct manipulation of entities; prefer calling domain methods.

abort_on_close: true
disable_cache: false

Expand Down
8 changes: 7 additions & 1 deletion .rules/checkstyle-rules.xml
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,13 @@ The following rules in the Naver coding convention cannot be checked by this con
value="[space-after-comma-semicolon]: ''{0}'' is not followed by whitespace."/>
</module>
<module name="NoWhitespaceBefore">
<property name="tokens" value="COMMA,SEMI"/>
<property name="tokens" value="COMMA"/>
<message key="ws.preceded"
value="[space-after-comma-semicolon] ''{0}'' is preceded with whitespace."/>
</module>
<module name="NoWhitespaceBefore">
<property name="tokens" value="SEMI"/>
<property name="allowLineBreaks" value="true"/>
<message key="ws.preceded"
value="[space-after-comma-semicolon] ''{0}'' is preceded with whitespace."/>
</module>
Expand Down
8 changes: 3 additions & 5 deletions src/main/java/queuing/core/CoreApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,7 @@
@SpringBootApplication
@ConfigurationPropertiesScan
public class CoreApplication {

public static void main(String[] args) {
SpringApplication.run(CoreApplication.class, args);
}

public static void main(String[] args) {
SpringApplication.run(CoreApplication.class, args);
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package queuing.core.friend.application.query;

import queuing.core.global.exception.BusinessException;
import queuing.core.global.exception.ErrorCode;

public record GetListFriendQuery(
String userSlug,
Long lastId,
int size
) {
public GetListFriendQuery {
if (userSlug == null || userSlug.isBlank()) {
throw new BusinessException(ErrorCode.COMMON_INVALID_INPUT);
}
userSlug = userSlug.strip();

if (lastId != null && lastId <= 0) {
throw new BusinessException(ErrorCode.COMMON_INVALID_INPUT);
}

if (size <= 0) {
throw new BusinessException(ErrorCode.COMMON_INVALID_INPUT);
}
if (size > 100) {
throw new BusinessException(ErrorCode.COMMON_INVALID_INPUT);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package queuing.core.friend.application.dto;
package queuing.core.friend.application.result;

import queuing.core.user.domain.entity.User;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@
import org.springframework.transaction.annotation.Transactional;

import lombok.RequiredArgsConstructor;
import queuing.core.friend.application.dto.FriendSummary;
import queuing.core.friend.application.dto.GetFriendListCommand;

import queuing.core.friend.application.query.GetListFriendQuery;
import queuing.core.friend.application.result.FriendSummary;
import queuing.core.friend.application.usecase.GetFriendListUseCase;
import queuing.core.friend.domain.entity.Friend;
import queuing.core.friend.domain.repository.FriendRepository;
Expand All @@ -21,21 +22,24 @@
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class FriendReadService implements GetFriendListUseCase {

private final UserRepository userRepository;
private final FriendRepository friendRepository;
private final UserRepository userRepository;

@Override
public SliceResult<FriendSummary> getFriendList(GetFriendListCommand command) {
User user = userRepository.findBySlug(command.userSlug())
public SliceResult<FriendSummary> getFriendList(GetListFriendQuery query) {
User user = userRepository.findBySlug(query.userSlug())
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));

SliceResult<Friend> friends = friendRepository.findFriendsByUserId(user.getId(), command.lastId(), command.size());
SliceResult<Friend> result = friendRepository.findFriendsByUserId(
user.getId(),
query.lastId(),
query.size()
);

List<FriendSummary> summaries = friends.items().stream()
List<FriendSummary> summaries = result.items().stream()
.map(friend -> FriendSummary.from(friend.getCounterpart(user)))
.toList();

return SliceResult.of(summaries, friends.hasNext());
return SliceResult.of(summaries, result.hasNext());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,19 +32,11 @@ public void sendRequest(String requesterSlug, String targetSlug) {
User target = userRepository.findBySlug(targetSlug)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));

if (requester.equals(target)) {
throw new BusinessException(ErrorCode.FRIEND_INVALID_REQUEST);
}

if (friendRepository.findRelationship(requester, target).isPresent()) {
throw new BusinessException(ErrorCode.FRIEND_ALREADY_EXISTS);
}

Friend friend = Friend.builder()
.requester(requester)
.receiver(target)
.status(FriendStatus.PENDING)
.build();
Friend friend = Friend.createRequest(requester, target);

friendRepository.save(friend);
}
Expand All @@ -58,15 +50,7 @@ public void acceptRequest(String userSlug, Long requestId) {
Friend friend = friendRepository.findById(requestId)
.orElseThrow(() -> new BusinessException(ErrorCode.FRIEND_REQUEST_NOT_FOUND));

if (!friend.getReceiver().equals(user)) {
throw new BusinessException(ErrorCode.FRIEND_INVALID_REQUEST);
}

if (friend.getStatus() != FriendStatus.PENDING) {
throw new BusinessException(ErrorCode.FRIEND_INVALID_REQUEST);
}

friend.updateStatus(FriendStatus.ACCEPTED);
friend.accept(user);
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@

public interface DeleteFriendUseCase {
void deleteFriend(String userSlug, String targetSlug);
}
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package queuing.core.friend.application.usecase;

import queuing.core.friend.application.dto.FriendSummary;
import queuing.core.friend.application.dto.GetFriendListCommand;
import queuing.core.friend.application.query.GetListFriendQuery;
import queuing.core.friend.application.result.FriendSummary;
import queuing.core.global.dto.SliceResult;

public interface GetFriendListUseCase {
SliceResult<FriendSummary> getFriendList(GetFriendListCommand command);
}
SliceResult<FriendSummary> getFriendList(GetListFriendQuery query);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@

public interface SendFriendRequestUseCase {
void sendRequest(String requesterSlug, String targetSlug);
}
}
44 changes: 44 additions & 0 deletions src/main/java/queuing/core/friend/domain/entity/Friend.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package queuing.core.friend.domain.entity;

import java.time.Instant;
import java.util.Objects;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
Expand All @@ -24,6 +25,9 @@
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import queuing.core.global.exception.BusinessException;
import queuing.core.global.exception.ErrorCode;
import queuing.core.user.domain.entity.User;

@Entity
Expand Down Expand Up @@ -76,4 +80,44 @@ public User getCounterpart(User user) {
}
return requester;
}

public static Friend createRequest(User requester, User receiver) {
if (requester.equals(receiver)) {
throw new BusinessException(ErrorCode.FRIEND_INVALID_REQUEST);
}
return Friend.builder()
.requester(requester)
.receiver(receiver)
.status(FriendStatus.PENDING)
.build();
}

public void accept(User user) {
if (!this.receiver.equals(user)) {
throw new BusinessException(ErrorCode.FRIEND_INVALID_REQUEST);
}

if (this.status != FriendStatus.PENDING) {
throw new BusinessException(ErrorCode.FRIEND_INVALID_REQUEST);
}

this.status = FriendStatus.ACCEPTED;
}

@Override
public final boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (!(obj instanceof Friend friend)) {
return false;
}

return this.getId() != null && Objects.equals(this.getId(), friend.getId());
}

@Override
public final int hashCode() {
return Objects.hash(this.getId());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,9 @@ public interface FriendRepository extends JpaRepository<Friend, Long>, FriendRep

Optional<Friend> findByRequesterAndReceiver(User requester, User receiver);

// Find a relationship in either direction
@Query("SELECT f FROM Friend f WHERE (f.requester = :user1 AND f.receiver = :user2) OR (f.requester = :user2 AND f.receiver = :user1)")
@Query("SELECT f FROM Friend f WHERE (f.requester = :user1 AND f.receiver = :user2) "
+ "OR (f.requester = :user2 AND f.receiver = :user1)")
Optional<Friend> findRelationship(@Param("user1") User user1, @Param("user2") User user2);

// Find pending requests received by user
Page<Friend> findByReceiverAndStatus(User receiver, FriendStatus status, Pageable pageable);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import com.querydsl.jpa.impl.JPAQueryFactory;

import lombok.RequiredArgsConstructor;

import queuing.core.friend.domain.entity.Friend;
import queuing.core.friend.domain.entity.FriendStatus;
import queuing.core.friend.domain.entity.QFriend;
Expand All @@ -25,7 +26,7 @@ public SliceResult<Friend> findFriendsByUserId(Long userId, Long lastId, int siz

BooleanExpression isFriend = (friend.requester.id.eq(userId).or(friend.receiver.id.eq(userId)))
.and(friend.status.eq(FriendStatus.ACCEPTED));

BooleanExpression lessThanId = (lastId != null) ? friend.id.lt(lastId) : null;

List<Friend> friends = query.selectFrom(friend)
Expand All @@ -44,4 +45,4 @@ public SliceResult<Friend> findFriendsByUserId(Long userId, Long lastId, int siz

return SliceResult.of(friends, hasNext);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@

import lombok.RequiredArgsConstructor;

import queuing.core.friend.application.dto.FriendSummary;
import queuing.core.friend.application.dto.GetFriendListCommand;
import queuing.core.friend.application.query.GetListFriendQuery;
import queuing.core.friend.application.result.FriendSummary;
import queuing.core.friend.application.usecase.DeleteFriendUseCase;
import queuing.core.friend.application.usecase.GetFriendListUseCase;
import queuing.core.friend.presentation.response.FriendResponse;
Expand Down Expand Up @@ -50,7 +50,7 @@ public ResponseEntity<ResponseBody<ListFriendResponse>> getFriends(
@RequestParam(defaultValue = "20") int size
) {
SliceResult<FriendSummary> result = getFriendListUseCase.getFriendList(
new GetFriendListCommand(principal.getUsername(), lastId, size)
new GetListFriendQuery(principal.getUsername(), lastId, size)
);

List<FriendResponse> items = result.items().stream()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@
public record SendFriendRequest(
@NotBlank(message = "친구 추가할 대상을 입력해주세요.")
String targetSlug
) {}
) {
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package queuing.core.friend.presentation.response;

import queuing.core.friend.application.dto.FriendSummary;
import queuing.core.friend.application.result.FriendSummary;

public record FriendResponse(
Long id,
Expand Down
Loading