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
9 changes: 8 additions & 1 deletion src/main/java/com/semosan/api/common/status/ErrorStatus.java
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ public enum ErrorStatus implements BaseStatus {
TRACKING_COURSE_ID_REQUIRED(HttpStatus.BAD_REQUEST, "TRK_400_2", "자유 기록이 아니면 코스 ID는 필수입니다."),
TRACKING_PHOTO_DUPLICATE(HttpStatus.CONFLICT, "TRK_409_3", "해당 마일스톤에 이미 업로드된 사진이 있습니다."),
TRACKING_PHOTO_SESSION_INACTIVE(HttpStatus.CONFLICT, "TRK_409_4", "활성 상태가 아닌 세션에는 사진을 업로드할 수 없습니다."),
TRACKING_PHOTO_NOT_FOUND(HttpStatus.NOT_FOUND, "TRK_404_2", "트래킹 사진을 찾을 수 없습니다."),
COURSE_NOT_FOUND(HttpStatus.NOT_FOUND, "MTN_404_3", "코스를 찾을 수 없습니다."),

/**
Expand All @@ -133,7 +134,13 @@ public enum ErrorStatus implements BaseStatus {
COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "CMT_404_1", "댓글을 찾을 수 없습니다."),
COMMENT_DELETED(HttpStatus.NOT_FOUND, "CMT_404_2", "삭제된 댓글입니다."),
COMMENT_FORBIDDEN(HttpStatus.FORBIDDEN, "CMT_403_1", "본인의 댓글만 처리할 수 있습니다."),
COMMENT_PARENT_POST_MISMATCH(HttpStatus.BAD_REQUEST, "CMT_400_1", "부모 댓글이 같은 게시글의 댓글이 아닙니다.");
COMMENT_PARENT_POST_MISMATCH(HttpStatus.BAD_REQUEST, "CMT_400_1", "부모 댓글이 같은 게시글의 댓글이 아닙니다."),

/**
* SemoFeed (세모피드)
*/
SEMOFEED_NOT_FOUND(HttpStatus.NOT_FOUND, "SF_404_1", "세모피드를 찾을 수 없습니다."),
SEMOFEED_FORBIDDEN(HttpStatus.FORBIDDEN, "SF_403_1", "본인의 세모피드만 처리할 수 있습니다.");

private final HttpStatus httpStatus;
private final String code;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ public enum SuccessStatus implements BaseStatus {
TRACKING_PHOTO_UPLOAD_SUCCESS(HttpStatus.CREATED, "TRK_201_2", "트래킹 사진이 저장되었습니다."),
TRACKING_PHOTO_LIST_SUCCESS(HttpStatus.OK, "TRK_200_8", "트래킹 사진 목록 조회에 성공했습니다."),

/**
* SemoFeed
*/
SEMOFEED_CREATE_SUCCESS(HttpStatus.CREATED, "SF_201_1", "세모피드가 저장되었습니다."),
SEMOFEED_LIST_SUCCESS(HttpStatus.OK, "SF_200_1", "세모피드 목록 조회에 성공했습니다."),
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", "세모피드가 삭제되었습니다."),

/**
* Image
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package com.semosan.api.domain.semofeed.controller;

import com.semosan.api.common.response.ApiResponse;
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.SemoFeedResponse;
import com.semosan.api.domain.semofeed.service.SemoFeedService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/semofeed")
@RequiredArgsConstructor
public class SemoFeedController implements SemoFeedControllerDocs {

private final SemoFeedService semoFeedService;

@PostMapping
@Override
public ResponseEntity<ApiResponse<SemoFeedResponse>> create(
@AuthenticationPrincipal Long userId,
@RequestBody String imageUrl
) {
SemoFeedResponse response = semoFeedService.create(userId, imageUrl);
return ApiResponse.success(SuccessStatus.SEMOFEED_CREATE_SUCCESS, response);
}

@GetMapping
@Override
public ResponseEntity<ApiResponse<PageResponse<SemoFeedResponse>>> listPublic(
@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))
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

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

@PatchMapping("/{semoFeedId}/public")
@Override
public ResponseEntity<ApiResponse<Boolean>> togglePublic(
@AuthenticationPrincipal Long userId,
@PathVariable Long semoFeedId
) {
boolean isPublic = semoFeedService.togglePublic(userId, semoFeedId);
return ApiResponse.success(SuccessStatus.SEMOFEED_TOGGLE_PUBLIC_SUCCESS, isPublic);
}

@DeleteMapping("/{semoFeedId}")
@Override
public ResponseEntity<ApiResponse<Void>> delete(
@AuthenticationPrincipal Long userId,
@PathVariable Long semoFeedId
) {
semoFeedService.delete(userId, semoFeedId);
return ApiResponse.success(SuccessStatus.SEMOFEED_DELETE_SUCCESS, null);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package com.semosan.api.domain.semofeed.controller.docs;

import com.semosan.api.common.response.ApiResponse;
import com.semosan.api.common.response.PageResponse;
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 org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;

import java.util.List;

@Tag(name = "SemoFeed", description = "세모피드 API")
public interface SemoFeedControllerDocs {

@Operation(
summary = "세모피드 저장",
description = "트래킹 사진으로 생성된 세모피드 이미지를 저장합니다. 저장 시 비공개 상태로 생성됩니다."
)
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "201",
description = "세모피드 저장 성공",
content = @Content(schema = @Schema(implementation = SemoFeedResponse.class))
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "401",
description = "인증 필요",
content = @Content(schema = @Schema(implementation = ApiResponse.class))
)
})
ResponseEntity<ApiResponse<SemoFeedResponse>> create(
@AuthenticationPrincipal Long userId,
@RequestBody String imageUrl
);

@Operation(
summary = "공개 세모피드 목록 조회",
description = "공개 상태인 세모피드를 최신순으로 최대 100개 페이징 조회합니다."
)
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "공개 세모피드 목록 조회 성공"
)
})
ResponseEntity<ApiResponse<PageResponse<SemoFeedResponse>>> listPublic(
@PageableDefault(size = 100) Pageable pageable
);

@Operation(
summary = "내 세모피드 목록 조회",
description = "로그인한 사용자의 세모피드를 최신순으로 전체 조회합니다."
)
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "내 세모피드 목록 조회 성공"
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "401",
description = "인증 필요",
content = @Content(schema = @Schema(implementation = ApiResponse.class))
)
})
ResponseEntity<ApiResponse<List<SemoFeedResponse>>> listMine(
@AuthenticationPrincipal Long userId
);

@Operation(
summary = "세모피드 공개/비공개 토글",
description = "본인의 세모피드 공개 상태를 토글합니다. 공개 시 모든 사용자의 피드에 노출됩니다."
)
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "공개 상태 변경 성공"
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "403",
description = "본인의 세모피드만 처리 가능 (SF_403_1)",
content = @Content(schema = @Schema(implementation = ApiResponse.class))
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "404",
description = "세모피드를 찾을 수 없음 (SF_404_1)",
content = @Content(schema = @Schema(implementation = ApiResponse.class))
)
})
ResponseEntity<ApiResponse<Boolean>> togglePublic(
@AuthenticationPrincipal Long userId,
@Parameter(description = "세모피드 ID", required = true)
@PathVariable Long semoFeedId
);

@Operation(
summary = "세모피드 삭제",
description = "본인의 세모피드를 삭제합니다."
)
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "세모피드 삭제 성공"
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "403",
description = "본인의 세모피드만 처리 가능 (SF_403_1)",
content = @Content(schema = @Schema(implementation = ApiResponse.class))
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "404",
description = "세모피드를 찾을 수 없음 (SF_404_1)",
content = @Content(schema = @Schema(implementation = ApiResponse.class))
)
})
ResponseEntity<ApiResponse<Void>> delete(
@AuthenticationPrincipal Long userId,
@Parameter(description = "세모피드 ID", required = true)
@PathVariable Long semoFeedId
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.semosan.api.domain.semofeed.dto;

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

public record SemoFeedResponse(
Long id,
String imageUrl,
boolean isPublic
) {
public static SemoFeedResponse from(SemoFeed semoFeed) {
return new SemoFeedResponse(
semoFeed.getId(),
semoFeed.getImageUrl(),
semoFeed.isPublic()
);
}
}
56 changes: 56 additions & 0 deletions src/main/java/com/semosan/api/domain/semofeed/entity/SemoFeed.java
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.user.entity.User;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Table(
name = "semo_feeds",
indexes = {
@Index(name = "idx_semo_feeds_user", columnList = "user_id"),
@Index(name = "idx_semo_feeds_public", columnList = "is_public")
}
)
@Getter
@Entity
@Builder(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class SemoFeed extends BaseEntity {

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

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

@Column(name = "image_url", nullable = false, length = 500)
private String imageUrl;

@Builder.Default
@Column(name = "is_public", nullable = false)
private boolean isPublic = false;

public static SemoFeed create(User user, String imageUrl) {
return SemoFeed.builder()
.user(user)
.imageUrl(imageUrl)
.isPublic(false)
.build();
}

public void togglePublic() {
this.isPublic = !this.isPublic;
}

public boolean isOwnedBy(Long userId) {
return this.user.getId().equals(userId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.semosan.api.domain.semofeed.repository;

import com.semosan.api.domain.semofeed.entity.SemoFeed;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

import java.util.List;

public interface SemoFeedRepository extends JpaRepository<SemoFeed, Long> {

@Query("SELECT s FROM SemoFeed s WHERE s.isPublic = true ORDER BY s.createdAt DESC")
Page<SemoFeed> findPublic(Pageable pageable);

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