Skip to content
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@
- `./gradlew build`
- If full tests fail because local infrastructure such as PostgreSQL or Redis is unavailable, report that clearly and distinguish it from failures caused by the code change.

## Git
- Commit messages must use the format `type: 한글 메시지`.
- Keep the type in English, such as `feat`, `fix`, `docs`, `test`, `refactor`, `chore`, and write the description in Korean.
- Split database migration changes into a separate commit from application code changes.

## Editing Rules
- Never revert or overwrite user changes unless explicitly requested.
- Keep changes narrowly scoped to the requested task.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ public enum SuccessStatus implements BaseStatus {
SEMOFEED_MY_LIST_SUCCESS(HttpStatus.OK, "SF_200_2", "내 세모피드 목록 조회에 성공했습니다."),
SEMOFEED_TOGGLE_PUBLIC_SUCCESS(HttpStatus.OK, "SF_200_3", "세모피드 공개 상태가 변경되었습니다."),
SEMOFEED_DELETE_SUCCESS(HttpStatus.OK, "SF_200_4", "세모피드가 삭제되었습니다."),
SEMOFEED_EMOJI_TOGGLE_SUCCESS(HttpStatus.OK, "SF_200_5", "세모피드 이모지 처리에 성공했습니다."),
SEMOFEED_CREATE_SUCCESS(HttpStatus.CREATED, "SF_201_1", "세모피드가 저장되었습니다."),

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@
import com.semosan.api.common.response.PageResponse;
import com.semosan.api.common.status.SuccessStatus;
import com.semosan.api.domain.semofeed.controller.docs.SemoFeedControllerDocs;
import com.semosan.api.domain.semofeed.dto.SemoFeedEmojiRequest;
import com.semosan.api.domain.semofeed.dto.SemoFeedEmojiToggleResponse;
import com.semosan.api.domain.semofeed.dto.SemoFeedResponse;
import com.semosan.api.domain.semofeed.service.SemoFeedEmojiService;
import com.semosan.api.domain.semofeed.service.SemoFeedService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
Expand All @@ -22,6 +26,7 @@
public class SemoFeedController implements SemoFeedControllerDocs {

private final SemoFeedService semoFeedService;
private final SemoFeedEmojiService semoFeedEmojiService;

@PostMapping
@Override
Expand All @@ -35,24 +40,37 @@ public ResponseEntity<ApiResponse<SemoFeedResponse>> create(

@GetMapping
@Override
// 공개 세모피드 목록을 작성자 정보와 이모지 상태까지 포함해 조회합니다.
public ResponseEntity<ApiResponse<PageResponse<SemoFeedResponse>>> listPublic(
@AuthenticationPrincipal Long userId,
@PageableDefault(size = 100) Pageable pageable
) {
if (pageable.getPageSize() > 100) {
pageable = PageRequest.of(pageable.getPageNumber(), 100, pageable.getSort());
}
return ApiResponse.success(
SuccessStatus.SEMOFEED_LIST_SUCCESS,
PageResponse.from(semoFeedService.listPublic(pageable))
);
PageResponse<SemoFeedResponse> response = PageResponse.from(semoFeedService.listPublic(pageable, userId));
return ApiResponse.success(SuccessStatus.SEMOFEED_LIST_SUCCESS, response);
}

@PostMapping("/{semoFeedId}/emojis")
@Override
// 세모피드 이모지를 타입별로 등록하거나 취소합니다.
public ResponseEntity<ApiResponse<SemoFeedEmojiToggleResponse>> toggleEmoji(
@AuthenticationPrincipal Long userId,
@PathVariable Long semoFeedId,
@Valid @RequestBody SemoFeedEmojiRequest request
) {
SemoFeedEmojiToggleResponse response = semoFeedEmojiService.toggleWithCount(userId, semoFeedId, request.emojiType());
return ApiResponse.success(SuccessStatus.SEMOFEED_EMOJI_TOGGLE_SUCCESS, response);
}

@GetMapping("/me")
@Override
public ResponseEntity<ApiResponse<List<SemoFeedResponse>>> listMine(
@AuthenticationPrincipal Long userId
) {
return ApiResponse.success(SuccessStatus.SEMOFEED_MY_LIST_SUCCESS, semoFeedService.listMine(userId));
List<SemoFeedResponse> response = semoFeedService.listMine(userId);
return ApiResponse.success(SuccessStatus.SEMOFEED_MY_LIST_SUCCESS, response);
}

@PatchMapping("/{semoFeedId}/public")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@

import com.semosan.api.common.response.ApiResponse;
import com.semosan.api.common.response.PageResponse;
import com.semosan.api.domain.semofeed.dto.SemoFeedEmojiRequest;
import com.semosan.api.domain.semofeed.dto.SemoFeedEmojiToggleResponse;
import com.semosan.api.domain.semofeed.dto.SemoFeedResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.ResponseEntity;
Expand Down Expand Up @@ -53,9 +56,33 @@ ResponseEntity<ApiResponse<SemoFeedResponse>> create(
)
})
ResponseEntity<ApiResponse<PageResponse<SemoFeedResponse>>> listPublic(
@AuthenticationPrincipal Long userId,
@PageableDefault(size = 100) Pageable pageable
);

@Operation(
summary = "세모피드 이모지 토글",
description = "세모피드에 FIRE, HEART, CONGRATS, LAUGH 이모지를 타입별로 등록하거나 취소합니다."
)
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "세모피드 이모지 처리 성공",
content = @Content(schema = @Schema(implementation = SemoFeedEmojiToggleResponse.class))
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "404",
description = "세모피드를 찾을 수 없음 (SF_404_1)",
content = @Content(schema = @Schema(implementation = ApiResponse.class))
)
})
ResponseEntity<ApiResponse<SemoFeedEmojiToggleResponse>> toggleEmoji(
@AuthenticationPrincipal Long userId,
@Parameter(description = "세모피드 ID", required = true)
@PathVariable Long semoFeedId,
@Valid @RequestBody SemoFeedEmojiRequest request
);

@Operation(
summary = "내 세모피드 목록 조회",
description = "로그인한 사용자의 세모피드를 최신순으로 전체 조회합니다."
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.semosan.api.domain.semofeed.dto;

import com.semosan.api.domain.semofeed.enums.SemoFeedEmojiType;
import jakarta.validation.constraints.NotNull;

public record SemoFeedEmojiRequest(
@NotNull(message = "emojiType 은 필수입니다.")
SemoFeedEmojiType emojiType
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.semosan.api.domain.semofeed.dto;

import com.semosan.api.domain.semofeed.enums.SemoFeedEmojiType;

public record SemoFeedEmojiToggleResponse(
SemoFeedEmojiType emojiType,
boolean reacted,
long count
) {
}
Original file line number Diff line number Diff line change
@@ -1,17 +1,65 @@
package com.semosan.api.domain.semofeed.dto;

import com.semosan.api.domain.semofeed.entity.SemoFeed;
import com.semosan.api.domain.semofeed.enums.SemoFeedEmojiType;

import java.util.EnumMap;
import java.util.Map;

public record SemoFeedResponse(
Long id,
Long userId,
String profileUrl,
String nickname,
String imageUrl,
boolean isPublic
boolean isPublic,
Map<SemoFeedEmojiType, Long> emojiCounts,
Map<SemoFeedEmojiType, Boolean> reactedByMe,
boolean mine
) {
public static SemoFeedResponse from(SemoFeed semoFeed) {
return of(
semoFeed,
defaultEmojiCounts(),
defaultReactedByMe(),
true
);
}

public static SemoFeedResponse of(
SemoFeed semoFeed,
Map<SemoFeedEmojiType, Long> emojiCounts,
Map<SemoFeedEmojiType, Boolean> reactedByMe,
boolean mine
) {
return new SemoFeedResponse(
semoFeed.getId(),
semoFeed.getUser().getId(),
semoFeed.getUser().getProfileUrl(),
semoFeed.getUser().displayName(),
semoFeed.getImageUrl(),
semoFeed.isPublic()
semoFeed.isPublic(),
emojiCounts,
reactedByMe,
mine
);
}

// 새로 생성된 세모피드 응답에 기본 이모지 개수를 채웁니다.
private static Map<SemoFeedEmojiType, Long> defaultEmojiCounts() {
Map<SemoFeedEmojiType, Long> emojiCounts = new EnumMap<>(SemoFeedEmojiType.class);
for (SemoFeedEmojiType emojiType : SemoFeedEmojiType.values()) {
emojiCounts.put(emojiType, 0L);
}
return emojiCounts;
}

// 새로 생성된 세모피드 응답에 기본 내 반응 상태를 채웁니다.
private static Map<SemoFeedEmojiType, Boolean> defaultReactedByMe() {
Map<SemoFeedEmojiType, Boolean> reactedByMe = new EnumMap<>(SemoFeedEmojiType.class);
for (SemoFeedEmojiType emojiType : SemoFeedEmojiType.values()) {
reactedByMe.put(emojiType, false);
}
return reactedByMe;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package com.semosan.api.domain.semofeed.entity;

import com.semosan.api.common.base.BaseEntity;
import com.semosan.api.domain.semofeed.enums.SemoFeedEmojiType;
import com.semosan.api.domain.user.entity.User;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Table(
name = "semo_feed_emojis",
uniqueConstraints = {
@UniqueConstraint(
name = "uk_semo_feed_emoji_feed_user_type",
columnNames = {"semo_feed_id", "user_id", "emoji_type"}
)
},
indexes = {
@Index(name = "idx_semo_feed_emojis_feed", columnList = "semo_feed_id"),
@Index(name = "idx_semo_feed_emojis_user", columnList = "user_id")
}
)
@Getter
@Entity
@Builder(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class SemoFeedEmoji extends BaseEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "semo_feed_id", nullable = false)
private SemoFeed semoFeed;

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

@Enumerated(EnumType.STRING)
@Column(name = "emoji_type", nullable = false, length = 20)
private SemoFeedEmojiType emojiType;

public static SemoFeedEmoji create(SemoFeed semoFeed, User user, SemoFeedEmojiType emojiType) {
return SemoFeedEmoji.builder()
.semoFeed(semoFeed)
.user(user)
.emojiType(emojiType)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.semosan.api.domain.semofeed.enums;

public enum SemoFeedEmojiType {
FIRE,
HEART,
CONGRATS,
LAUGH
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.semosan.api.domain.semofeed.repository;

import com.semosan.api.domain.semofeed.entity.SemoFeed;
import com.semosan.api.domain.semofeed.entity.SemoFeedEmoji;
import com.semosan.api.domain.semofeed.enums.SemoFeedEmojiType;
import com.semosan.api.domain.user.entity.User;
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;
import java.util.Optional;

public interface SemoFeedEmojiRepository extends JpaRepository<SemoFeedEmoji, Long> {

Optional<SemoFeedEmoji> findBySemoFeedAndUserAndEmojiType(
SemoFeed semoFeed,
User user,
SemoFeedEmojiType emojiType
);

long countBySemoFeedAndEmojiType(SemoFeed semoFeed, SemoFeedEmojiType emojiType);

@Query("""
SELECT e.semoFeed.id, e.emojiType, COUNT(e)
FROM SemoFeedEmoji e
WHERE e.semoFeed.id IN :semoFeedIds
GROUP BY e.semoFeed.id, e.emojiType
""")
List<Object[]> countBySemoFeedIdsGrouped(@Param("semoFeedIds") List<Long> semoFeedIds);

@Query("""
SELECT e.semoFeed.id, e.emojiType
FROM SemoFeedEmoji e
WHERE e.semoFeed.id IN :semoFeedIds
AND e.user.id = :userId
""")
List<Object[]> findReactedTypesBySemoFeedIdsAndUserId(
@Param("semoFeedIds") List<Long> semoFeedIds,
@Param("userId") Long userId
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@

public interface SemoFeedRepository extends JpaRepository<SemoFeed, Long> {

@Query("SELECT s FROM SemoFeed s WHERE s.isPublic = true ORDER BY s.createdAt DESC")
@Query(
value = "SELECT s FROM SemoFeed s JOIN FETCH s.user WHERE s.isPublic = true ORDER BY s.createdAt DESC",
countQuery = "SELECT COUNT(s) FROM SemoFeed s WHERE s.isPublic = true"
)
Page<SemoFeed> findPublic(Pageable pageable);

@Query("SELECT s FROM SemoFeed s WHERE s.user.id = :userId ORDER BY s.createdAt DESC")
@Query("SELECT s FROM SemoFeed s JOIN FETCH s.user WHERE s.user.id = :userId ORDER BY s.createdAt DESC")
List<SemoFeed> findByUserId(Long userId);
}
Loading