Skip to content

Commit 315ebde

Browse files
authored
Merge pull request #98 from SEMOSAN/feat/#91-semofeed
[Feat] 세모피드 도메인 구현
2 parents 48cfa7b + 90cb499 commit 315ebde

9 files changed

Lines changed: 396 additions & 1 deletion

File tree

src/main/java/com/semosan/api/common/status/ErrorStatus.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ public enum ErrorStatus implements BaseStatus {
116116
TRACKING_COURSE_ID_REQUIRED(HttpStatus.BAD_REQUEST, "TRK_400_2", "자유 기록이 아니면 코스 ID는 필수입니다."),
117117
TRACKING_PHOTO_DUPLICATE(HttpStatus.CONFLICT, "TRK_409_3", "해당 마일스톤에 이미 업로드된 사진이 있습니다."),
118118
TRACKING_PHOTO_SESSION_INACTIVE(HttpStatus.CONFLICT, "TRK_409_4", "활성 상태가 아닌 세션에는 사진을 업로드할 수 없습니다."),
119+
TRACKING_PHOTO_NOT_FOUND(HttpStatus.NOT_FOUND, "TRK_404_2", "트래킹 사진을 찾을 수 없습니다."),
119120
COURSE_NOT_FOUND(HttpStatus.NOT_FOUND, "MTN_404_3", "코스를 찾을 수 없습니다."),
120121

121122
/**
@@ -133,7 +134,13 @@ public enum ErrorStatus implements BaseStatus {
133134
COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "CMT_404_1", "댓글을 찾을 수 없습니다."),
134135
COMMENT_DELETED(HttpStatus.NOT_FOUND, "CMT_404_2", "삭제된 댓글입니다."),
135136
COMMENT_FORBIDDEN(HttpStatus.FORBIDDEN, "CMT_403_1", "본인의 댓글만 처리할 수 있습니다."),
136-
COMMENT_PARENT_POST_MISMATCH(HttpStatus.BAD_REQUEST, "CMT_400_1", "부모 댓글이 같은 게시글의 댓글이 아닙니다.");
137+
COMMENT_PARENT_POST_MISMATCH(HttpStatus.BAD_REQUEST, "CMT_400_1", "부모 댓글이 같은 게시글의 댓글이 아닙니다."),
138+
139+
/**
140+
* SemoFeed (세모피드)
141+
*/
142+
SEMOFEED_NOT_FOUND(HttpStatus.NOT_FOUND, "SF_404_1", "세모피드를 찾을 수 없습니다."),
143+
SEMOFEED_FORBIDDEN(HttpStatus.FORBIDDEN, "SF_403_1", "본인의 세모피드만 처리할 수 있습니다.");
137144

138145
private final HttpStatus httpStatus;
139146
private final String code;

src/main/java/com/semosan/api/common/status/SuccessStatus.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,15 @@ public enum SuccessStatus implements BaseStatus {
6666
TRACKING_PHOTO_UPLOAD_SUCCESS(HttpStatus.CREATED, "TRK_201_2", "트래킹 사진이 저장되었습니다."),
6767
TRACKING_PHOTO_LIST_SUCCESS(HttpStatus.OK, "TRK_200_8", "트래킹 사진 목록 조회에 성공했습니다."),
6868

69+
/**
70+
* SemoFeed
71+
*/
72+
SEMOFEED_CREATE_SUCCESS(HttpStatus.CREATED, "SF_201_1", "세모피드가 저장되었습니다."),
73+
SEMOFEED_LIST_SUCCESS(HttpStatus.OK, "SF_200_1", "세모피드 목록 조회에 성공했습니다."),
74+
SEMOFEED_MY_LIST_SUCCESS(HttpStatus.OK, "SF_200_2", "내 세모피드 목록 조회에 성공했습니다."),
75+
SEMOFEED_TOGGLE_PUBLIC_SUCCESS(HttpStatus.OK, "SF_200_3", "세모피드 공개 상태가 변경되었습니다."),
76+
SEMOFEED_DELETE_SUCCESS(HttpStatus.OK, "SF_200_4", "세모피드가 삭제되었습니다."),
77+
6978
/**
7079
* Image
7180
*/
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package com.semosan.api.domain.semofeed.controller;
2+
3+
import com.semosan.api.common.response.ApiResponse;
4+
import com.semosan.api.common.response.PageResponse;
5+
import com.semosan.api.common.status.SuccessStatus;
6+
import com.semosan.api.domain.semofeed.controller.docs.SemoFeedControllerDocs;
7+
import com.semosan.api.domain.semofeed.dto.SemoFeedResponse;
8+
import com.semosan.api.domain.semofeed.service.SemoFeedService;
9+
import lombok.RequiredArgsConstructor;
10+
import org.springframework.data.domain.PageRequest;
11+
import org.springframework.data.domain.Pageable;
12+
import org.springframework.data.web.PageableDefault;
13+
import org.springframework.http.ResponseEntity;
14+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
15+
import org.springframework.web.bind.annotation.*;
16+
17+
import java.util.List;
18+
19+
@RestController
20+
@RequestMapping("/api/semofeed")
21+
@RequiredArgsConstructor
22+
public class SemoFeedController implements SemoFeedControllerDocs {
23+
24+
private final SemoFeedService semoFeedService;
25+
26+
@PostMapping
27+
@Override
28+
public ResponseEntity<ApiResponse<SemoFeedResponse>> create(
29+
@AuthenticationPrincipal Long userId,
30+
@RequestBody String imageUrl
31+
) {
32+
SemoFeedResponse response = semoFeedService.create(userId, imageUrl);
33+
return ApiResponse.success(SuccessStatus.SEMOFEED_CREATE_SUCCESS, response);
34+
}
35+
36+
@GetMapping
37+
@Override
38+
public ResponseEntity<ApiResponse<PageResponse<SemoFeedResponse>>> listPublic(
39+
@PageableDefault(size = 100) Pageable pageable
40+
) {
41+
if (pageable.getPageSize() > 100) {
42+
pageable = PageRequest.of(pageable.getPageNumber(), 100, pageable.getSort());
43+
}
44+
return ApiResponse.success(
45+
SuccessStatus.SEMOFEED_LIST_SUCCESS,
46+
PageResponse.from(semoFeedService.listPublic(pageable))
47+
);
48+
}
49+
50+
@GetMapping("/me")
51+
@Override
52+
public ResponseEntity<ApiResponse<List<SemoFeedResponse>>> listMine(
53+
@AuthenticationPrincipal Long userId
54+
) {
55+
return ApiResponse.success(SuccessStatus.SEMOFEED_MY_LIST_SUCCESS, semoFeedService.listMine(userId));
56+
}
57+
58+
@PatchMapping("/{semoFeedId}/public")
59+
@Override
60+
public ResponseEntity<ApiResponse<Boolean>> togglePublic(
61+
@AuthenticationPrincipal Long userId,
62+
@PathVariable Long semoFeedId
63+
) {
64+
boolean isPublic = semoFeedService.togglePublic(userId, semoFeedId);
65+
return ApiResponse.success(SuccessStatus.SEMOFEED_TOGGLE_PUBLIC_SUCCESS, isPublic);
66+
}
67+
68+
@DeleteMapping("/{semoFeedId}")
69+
@Override
70+
public ResponseEntity<ApiResponse<Void>> delete(
71+
@AuthenticationPrincipal Long userId,
72+
@PathVariable Long semoFeedId
73+
) {
74+
semoFeedService.delete(userId, semoFeedId);
75+
return ApiResponse.success(SuccessStatus.SEMOFEED_DELETE_SUCCESS, null);
76+
}
77+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package com.semosan.api.domain.semofeed.controller.docs;
2+
3+
import com.semosan.api.common.response.ApiResponse;
4+
import com.semosan.api.common.response.PageResponse;
5+
import com.semosan.api.domain.semofeed.dto.SemoFeedResponse;
6+
import io.swagger.v3.oas.annotations.Operation;
7+
import io.swagger.v3.oas.annotations.Parameter;
8+
import io.swagger.v3.oas.annotations.media.Content;
9+
import io.swagger.v3.oas.annotations.media.Schema;
10+
import io.swagger.v3.oas.annotations.responses.ApiResponses;
11+
import io.swagger.v3.oas.annotations.tags.Tag;
12+
import org.springframework.data.domain.Pageable;
13+
import org.springframework.data.web.PageableDefault;
14+
import org.springframework.http.ResponseEntity;
15+
import org.springframework.security.core.annotation.AuthenticationPrincipal;
16+
import org.springframework.web.bind.annotation.PathVariable;
17+
import org.springframework.web.bind.annotation.RequestBody;
18+
19+
import java.util.List;
20+
21+
@Tag(name = "SemoFeed", description = "세모피드 API")
22+
public interface SemoFeedControllerDocs {
23+
24+
@Operation(
25+
summary = "세모피드 저장",
26+
description = "트래킹 사진으로 생성된 세모피드 이미지를 저장합니다. 저장 시 비공개 상태로 생성됩니다."
27+
)
28+
@ApiResponses({
29+
@io.swagger.v3.oas.annotations.responses.ApiResponse(
30+
responseCode = "201",
31+
description = "세모피드 저장 성공",
32+
content = @Content(schema = @Schema(implementation = SemoFeedResponse.class))
33+
),
34+
@io.swagger.v3.oas.annotations.responses.ApiResponse(
35+
responseCode = "401",
36+
description = "인증 필요",
37+
content = @Content(schema = @Schema(implementation = ApiResponse.class))
38+
)
39+
})
40+
ResponseEntity<ApiResponse<SemoFeedResponse>> create(
41+
@AuthenticationPrincipal Long userId,
42+
@RequestBody String imageUrl
43+
);
44+
45+
@Operation(
46+
summary = "공개 세모피드 목록 조회",
47+
description = "공개 상태인 세모피드를 최신순으로 최대 100개 페이징 조회합니다."
48+
)
49+
@ApiResponses({
50+
@io.swagger.v3.oas.annotations.responses.ApiResponse(
51+
responseCode = "200",
52+
description = "공개 세모피드 목록 조회 성공"
53+
)
54+
})
55+
ResponseEntity<ApiResponse<PageResponse<SemoFeedResponse>>> listPublic(
56+
@PageableDefault(size = 100) Pageable pageable
57+
);
58+
59+
@Operation(
60+
summary = "내 세모피드 목록 조회",
61+
description = "로그인한 사용자의 세모피드를 최신순으로 전체 조회합니다."
62+
)
63+
@ApiResponses({
64+
@io.swagger.v3.oas.annotations.responses.ApiResponse(
65+
responseCode = "200",
66+
description = "내 세모피드 목록 조회 성공"
67+
),
68+
@io.swagger.v3.oas.annotations.responses.ApiResponse(
69+
responseCode = "401",
70+
description = "인증 필요",
71+
content = @Content(schema = @Schema(implementation = ApiResponse.class))
72+
)
73+
})
74+
ResponseEntity<ApiResponse<List<SemoFeedResponse>>> listMine(
75+
@AuthenticationPrincipal Long userId
76+
);
77+
78+
@Operation(
79+
summary = "세모피드 공개/비공개 토글",
80+
description = "본인의 세모피드 공개 상태를 토글합니다. 공개 시 모든 사용자의 피드에 노출됩니다."
81+
)
82+
@ApiResponses({
83+
@io.swagger.v3.oas.annotations.responses.ApiResponse(
84+
responseCode = "200",
85+
description = "공개 상태 변경 성공"
86+
),
87+
@io.swagger.v3.oas.annotations.responses.ApiResponse(
88+
responseCode = "403",
89+
description = "본인의 세모피드만 처리 가능 (SF_403_1)",
90+
content = @Content(schema = @Schema(implementation = ApiResponse.class))
91+
),
92+
@io.swagger.v3.oas.annotations.responses.ApiResponse(
93+
responseCode = "404",
94+
description = "세모피드를 찾을 수 없음 (SF_404_1)",
95+
content = @Content(schema = @Schema(implementation = ApiResponse.class))
96+
)
97+
})
98+
ResponseEntity<ApiResponse<Boolean>> togglePublic(
99+
@AuthenticationPrincipal Long userId,
100+
@Parameter(description = "세모피드 ID", required = true)
101+
@PathVariable Long semoFeedId
102+
);
103+
104+
@Operation(
105+
summary = "세모피드 삭제",
106+
description = "본인의 세모피드를 삭제합니다."
107+
)
108+
@ApiResponses({
109+
@io.swagger.v3.oas.annotations.responses.ApiResponse(
110+
responseCode = "200",
111+
description = "세모피드 삭제 성공"
112+
),
113+
@io.swagger.v3.oas.annotations.responses.ApiResponse(
114+
responseCode = "403",
115+
description = "본인의 세모피드만 처리 가능 (SF_403_1)",
116+
content = @Content(schema = @Schema(implementation = ApiResponse.class))
117+
),
118+
@io.swagger.v3.oas.annotations.responses.ApiResponse(
119+
responseCode = "404",
120+
description = "세모피드를 찾을 수 없음 (SF_404_1)",
121+
content = @Content(schema = @Schema(implementation = ApiResponse.class))
122+
)
123+
})
124+
ResponseEntity<ApiResponse<Void>> delete(
125+
@AuthenticationPrincipal Long userId,
126+
@Parameter(description = "세모피드 ID", required = true)
127+
@PathVariable Long semoFeedId
128+
);
129+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package com.semosan.api.domain.semofeed.dto;
2+
3+
import com.semosan.api.domain.semofeed.entity.SemoFeed;
4+
5+
public record SemoFeedResponse(
6+
Long id,
7+
String imageUrl,
8+
boolean isPublic
9+
) {
10+
public static SemoFeedResponse from(SemoFeed semoFeed) {
11+
return new SemoFeedResponse(
12+
semoFeed.getId(),
13+
semoFeed.getImageUrl(),
14+
semoFeed.isPublic()
15+
);
16+
}
17+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package com.semosan.api.domain.semofeed.entity;
2+
3+
import com.semosan.api.common.base.BaseEntity;
4+
import com.semosan.api.domain.user.entity.User;
5+
import jakarta.persistence.*;
6+
import lombok.AccessLevel;
7+
import lombok.AllArgsConstructor;
8+
import lombok.Builder;
9+
import lombok.Getter;
10+
import lombok.NoArgsConstructor;
11+
12+
@Table(
13+
name = "semo_feeds",
14+
indexes = {
15+
@Index(name = "idx_semo_feeds_user", columnList = "user_id"),
16+
@Index(name = "idx_semo_feeds_public", columnList = "is_public")
17+
}
18+
)
19+
@Getter
20+
@Entity
21+
@Builder(access = AccessLevel.PROTECTED)
22+
@AllArgsConstructor(access = AccessLevel.PROTECTED)
23+
@NoArgsConstructor(access = AccessLevel.PROTECTED)
24+
public class SemoFeed extends BaseEntity {
25+
26+
@Id
27+
@GeneratedValue(strategy = GenerationType.IDENTITY)
28+
private Long id;
29+
30+
@ManyToOne(fetch = FetchType.LAZY)
31+
@JoinColumn(name = "user_id", nullable = false)
32+
private User user;
33+
34+
@Column(name = "image_url", nullable = false, length = 500)
35+
private String imageUrl;
36+
37+
@Builder.Default
38+
@Column(name = "is_public", nullable = false)
39+
private boolean isPublic = false;
40+
41+
public static SemoFeed create(User user, String imageUrl) {
42+
return SemoFeed.builder()
43+
.user(user)
44+
.imageUrl(imageUrl)
45+
.isPublic(false)
46+
.build();
47+
}
48+
49+
public void togglePublic() {
50+
this.isPublic = !this.isPublic;
51+
}
52+
53+
public boolean isOwnedBy(Long userId) {
54+
return this.user.getId().equals(userId);
55+
}
56+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.semosan.api.domain.semofeed.repository;
2+
3+
import com.semosan.api.domain.semofeed.entity.SemoFeed;
4+
import org.springframework.data.domain.Page;
5+
import org.springframework.data.domain.Pageable;
6+
import org.springframework.data.jpa.repository.JpaRepository;
7+
import org.springframework.data.jpa.repository.Query;
8+
9+
import java.util.List;
10+
11+
public interface SemoFeedRepository extends JpaRepository<SemoFeed, Long> {
12+
13+
@Query("SELECT s FROM SemoFeed s WHERE s.isPublic = true ORDER BY s.createdAt DESC")
14+
Page<SemoFeed> findPublic(Pageable pageable);
15+
16+
@Query("SELECT s FROM SemoFeed s WHERE s.user.id = :userId ORDER BY s.createdAt DESC")
17+
List<SemoFeed> findByUserId(Long userId);
18+
}

0 commit comments

Comments
 (0)