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
57 changes: 57 additions & 0 deletions .github/workflows/cd-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,63 @@ jobs:
target: /home/ubuntu/deploy
overwrite: true

- name: Copy Nginx Config to EC2
uses: appleboy/scp-action@v1
with:
host: ${{ secrets.PROD_EC2_HOST }}
username: ${{ secrets.PROD_EC2_USERNAME }}
key: ${{ secrets.PROD_EC2_SSH_KEY }}
source: infra/nginx/conf.d/upload-limits.conf
target: /home/ubuntu/deploy
overwrite: true

# nginx 설정이 서버에만 있으면 인스턴스를 새로 띄울 때 기본값으로 돌아간다.
# 리포의 파일을 매 배포마다 반영해서 서버 상태가 코드와 갈라지지 않게 한다.
- name: Apply Nginx Config
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.PROD_EC2_HOST }}
username: ${{ secrets.PROD_EC2_USERNAME }}
key: ${{ secrets.PROD_EC2_SSH_KEY }}
script: |
set -eu

SOURCE="/home/ubuntu/deploy/infra/nginx/conf.d/upload-limits.conf"
TARGET="/etc/nginx/conf.d/upload-limits.conf"
# conf.d 안에 두면 include 대상이 될 수 있어 밖에 백업한다.
BACKUP="/tmp/upload-limits.conf.bak"

if sudo cmp -s "$SOURCE" "$TARGET"; then
echo "nginx 설정 변경 없음. reload 를 건너뜁니다."
exit 0
fi

# 검사에 실패했을 때 지워버리면, 실행 중인 nginx 는 멀쩡해도 다음 reload 때
# 한도 설정이 통째로 사라진다. 되돌릴 수 있게 원본을 먼저 남긴다.
if sudo test -e "$TARGET"; then
sudo cp "$TARGET" "$BACKUP"
HAD_TARGET=1
else
HAD_TARGET=0
fi

sudo cp "$SOURCE" "$TARGET"

# 문법 검사를 통과할 때만 reload 한다. 실패하면 실행 중인 nginx 는 그대로 둔다.
if ! sudo nginx -t; then
echo "nginx 설정 검사 실패. 이전 설정으로 되돌립니다."
if [ "$HAD_TARGET" -eq 1 ]; then
sudo mv "$BACKUP" "$TARGET"
else
sudo rm -f "$TARGET"
fi
exit 1
Comment on lines +94 to +104

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

기존 Nginx 설정을 삭제하지 말고 복원하십시오.

Line 86의 nginx -t가 실패하면 Line 88이 현재 TARGET을 삭제합니다. 기존 upload-limits.conf가 있던 서버에서는 이전 설정이 복원되지 않습니다. 이후 Nginx가 재시작되면 업로드 제한과 타임아웃 설정이 사라집니다.

교체 전 TARGET을 백업하십시오. 검증 실패 시 백업을 다시 복원하십시오. 대상 파일이 원래 없던 경우에만 삭제하십시오.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/cd-prod.yml around lines 83 - 89, Update the Nginx
replacement flow around TARGET and the nginx -t check to back up the existing
TARGET before copying SOURCE. On validation failure, restore that backup when
TARGET originally existed; only remove TARGET when no original file was present,
preserving the current failure exit behavior.

fi

sudo rm -f "$BACKUP"
sudo systemctl reload nginx
echo "nginx 설정 반영 완료"

- name: Deploy Docker Container
uses: appleboy/ssh-action@v1
with:
Expand Down
18 changes: 18 additions & 0 deletions infra/nginx/conf.d/upload-limits.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# nginx 업로드 제한. /etc/nginx/conf.d/ 에 놓이며 http 블록 안에서 include 된다.
#
# 이 파일이 없으면 nginx 는 client_max_body_size 기본값 1MB 로 동작한다.
# 앱과 프론트가 100MB 로 맞춰져 있어도 1MB 를 넘는 요청은 nginx 가 413 으로 끊어
# 스프링에 닿지도 않는다. 그래서 서비스 코드의 검증 로직은 실행조차 되지 않고,
# 응답도 공통 래퍼가 아닌 nginx 기본 HTML 이라 프론트가 원인을 표시하지 못한다.
#
# certbot 이 관리하는 server 블록은 건드리지 않는다.
# 여기에 두면 http 레벨에 적용되어 모든 server/location 으로 상속된다.

# 앱의 spring.servlet.multipart.max-request-size(105MB)와 맞춘다.
# 파일 본문에 JSON 파트와 멀티파트 경계가 더해지므로 개별 파일 한도(100MB)보다 커야 한다.
client_max_body_size 105M;

# 큰 파일은 앱이 S3 업로드를 마친 뒤에야 응답한다.
# 기본값 60초로는 100MB 근처에서 504 가 난다. 파일은 올라갔는데 실패로 보이는 상태가 된다.
proxy_read_timeout 300s;
proxy_send_timeout 300s;
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import com.slatto.domain.feedback.dto.request.FeedbackRequest.FeedbackStatusReqDTO;
import com.slatto.domain.feedback.dto.response.FeedbackResponse.FeedbackStatusResDTO;
import com.slatto.domain.feedback.service.FeedbackService;
import com.slatto.global.config.OptionalAuthentication;
import com.slatto.global.response.ApiResponse;
import com.slatto.global.response.code.CommonSuccessCode;
import io.swagger.v3.oas.annotations.Operation;
Expand All @@ -28,6 +29,7 @@ public class FeedbackController {
private final FeedbackService feedbackService;

@Operation(summary = "피드백 작성")
@OptionalAuthentication
@PostMapping("/videos/{videoId}/feedbacks")
public ResponseEntity<ApiResponse<FeedbackCreateResDTO>> createFeedback(
@PathVariable Long videoId,
Expand All @@ -42,6 +44,7 @@ public ResponseEntity<ApiResponse<FeedbackCreateResDTO>> createFeedback(
}

@Operation(summary = "피드백 수정")
@OptionalAuthentication
@PatchMapping("/feedbacks/{feedbackId}")
public ResponseEntity<ApiResponse<FeedbackUpdateResDTO>> updateFeedback(
@PathVariable Long feedbackId,
Expand All @@ -55,6 +58,7 @@ public ResponseEntity<ApiResponse<FeedbackUpdateResDTO>> updateFeedback(
}

@Operation(summary = "피드백 삭제")
@OptionalAuthentication
@DeleteMapping("/feedbacks/{feedbackId}")
public ResponseEntity<ApiResponse<Void>> deleteFeedback(
@PathVariable Long feedbackId,
Expand All @@ -68,6 +72,7 @@ public ResponseEntity<ApiResponse<Void>> deleteFeedback(
}

@Operation(summary = "피드백 목록 조회")
@OptionalAuthentication
@GetMapping("/videos/{videoId}/feedbacks")
public ResponseEntity<ApiResponse<FeedbackListResDTO>> getFeedbackList(
@PathVariable Long videoId,
Expand All @@ -82,7 +87,10 @@ public ResponseEntity<ApiResponse<FeedbackListResDTO>> getFeedbackList(
.ok(ApiResponse.success(CommonSuccessCode.OK, result));
}

@Operation(summary = "피드백 해결 상태 변경")
@Operation(
summary = "피드백 해결 상태 변경",
description = "활성 프로젝트 멤버만 변경할 수 있다. 다른 피드백 API 와 달리 게스트는 호출할 수 없다."
)
@PatchMapping("/feedbacks/{feedbackId}/status")
public ResponseEntity<ApiResponse<FeedbackStatusResDTO>> changeFeedbackStatus(
@PathVariable Long feedbackId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import com.slatto.domain.feedback.dto.request.FeedbackDetailRequest.ReplyStatusReqDTO;
import com.slatto.domain.feedback.dto.response.FeedbackDetailResponse.ReplyStatusResDTO;
import com.slatto.domain.feedback.service.FeedbackDetailService;
import com.slatto.global.config.OptionalAuthentication;
import com.slatto.global.response.ApiResponse;
import com.slatto.global.response.code.CommonSuccessCode;
import io.swagger.v3.oas.annotations.Operation;
Expand All @@ -28,6 +29,7 @@ public class FeedbackDetailController {
private final FeedbackDetailService feedbackDetailService;

@Operation(summary = "답글 작성")
@OptionalAuthentication
@PostMapping("/feedbacks/{feedbackId}/replies")
public ResponseEntity<ApiResponse<ReplyCreateResDTO>> createReply(
@PathVariable Long feedbackId,
Expand All @@ -43,6 +45,7 @@ public ResponseEntity<ApiResponse<ReplyCreateResDTO>> createReply(
}

@Operation(summary = "답글 목록 조회")
@OptionalAuthentication
@GetMapping("/feedbacks/{feedbackId}/replies")
public ResponseEntity<ApiResponse<ReplyListResDTO>> getReplyList(
@PathVariable Long feedbackId,
Expand All @@ -59,6 +62,7 @@ public ResponseEntity<ApiResponse<ReplyListResDTO>> getReplyList(
}

@Operation(summary = "답글 수정")
@OptionalAuthentication
@PatchMapping("/replies/{replyId}")
public ResponseEntity<ApiResponse<ReplyUpdateResDTO>> updateReply(
@PathVariable Long replyId,
Expand All @@ -73,6 +77,7 @@ public ResponseEntity<ApiResponse<ReplyUpdateResDTO>> updateReply(
}

@Operation(summary = "답글 삭제")
@OptionalAuthentication
@DeleteMapping("/replies/{replyId}")
public ResponseEntity<ApiResponse<Void>> deleteReply(
@PathVariable Long replyId,
Expand All @@ -86,7 +91,10 @@ public ResponseEntity<ApiResponse<Void>> deleteReply(
.ok(ApiResponse.success(CommonSuccessCode.OK, null));
}

@Operation(summary = "답글 해결 상태 변경")
@Operation(
summary = "답글 해결 상태 변경",
description = "활성 프로젝트 멤버만 변경할 수 있다. 다른 답글 API 와 달리 게스트는 호출할 수 없다."
)
@PatchMapping("/replies/{replyId}/status")
public ResponseEntity<ApiResponse<ReplyStatusResDTO>> changeReplyStatus(
@PathVariable Long replyId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ public class FeedbackResponse {
public record ActorDTO(
@Schema(example = "USER") String type, // "USER" 또는 "GUEST"
@Schema(example = "20") Long id,
@Schema(example = "김수민") String name
@Schema(example = "차태훈") String name
) {
// 회원이면 USER actor 만들기
public static ActorDTO fromUser(Users user) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,12 @@ public class NotificationController {

private final NotificationService notificationService;

@Operation(summary = "알림 목록 조회")
@Operation(
summary = "알림 목록 조회",
description = """
최근 24시간 이내에 갱신된 알림만 반환한다.
읽지 않은 알림이 먼저 오고, 그 다음은 최근에 갱신된 순이다. cursor 기반 페이지네이션이며 size 는 기본 20이다."""
)
@GetMapping
public ApiResponse<NotificationListResponse> getNotifications(
@AuthenticationPrincipal Long currentUserId,
Expand All @@ -39,7 +44,10 @@ public ApiResponse<NotificationListResponse> getNotifications(
return ApiResponse.success(CommonSuccessCode.OK, response);
}

@Operation(summary = "알림 단건 읽음 처리")
@Operation(
summary = "알림 단건 읽음 처리",
description = "본인에게 온 알림만 처리할 수 있다."
)
@PatchMapping("/{notificationId}/read")
public ApiResponse<Void> markNotificationAsRead(
@AuthenticationPrincipal Long currentUserId,
Expand All @@ -50,7 +58,12 @@ public ApiResponse<Void> markNotificationAsRead(
return ApiResponse.success(CommonSuccessCode.OK, null);
}

@Operation(summary = "알림 전체 읽음 처리")
@Operation(
summary = "알림 전체 읽음 처리",
description = """
읽지 않은 알림을 모두 읽음으로 바꾼다.
목록 조회와 달리 24시간 제한이 없어서, 목록에 보이지 않는 오래된 알림도 함께 처리된다."""
)
@PatchMapping("/read-all")
public ApiResponse<Void> markAllNotificationsAsRead(
@AuthenticationPrincipal Long currentUserId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,12 @@ public class RecentActivityController {

private final RecentActivityService recentActivityService;

@Operation(summary = "프로젝트 최근활동 목록 조회")
@Operation(
summary = "프로젝트 최근활동 목록 조회",
description = """
프로젝트 멤버만 조회할 수 있다. 각 항목에 내가 읽었는지 여부가 함께 담긴다.
cursor 는 응답의 nextCursor 를 그대로 넘기는 문자열이며 size 는 기본 20, 최대 50이다."""
)
@GetMapping
public ApiResponse<ActivityLogListResponse> getRecentActivities(
@AuthenticationPrincipal Long currentUserId,
Expand All @@ -41,7 +46,10 @@ public ApiResponse<ActivityLogListResponse> getRecentActivities(
return ApiResponse.success(CommonSuccessCode.OK, response);
}

@Operation(summary = "프로젝트 최근활동 단건 읽음 처리")
@Operation(
summary = "프로젝트 최근활동 단건 읽음 처리",
description = "읽음은 호출한 사람에게만 기록된다. 이미 읽은 활동에 다시 호출해도 실패하지 않는다."
)
@PatchMapping("/{activityId}/read")
public ApiResponse<Void> markActivityAsRead(
@AuthenticationPrincipal Long currentUserId,
Expand All @@ -53,7 +61,10 @@ public ApiResponse<Void> markActivityAsRead(
return ApiResponse.success(CommonSuccessCode.OK, null);
}

@Operation(summary = "프로젝트 최근활동 전체 읽음 처리")
@Operation(
summary = "프로젝트 최근활동 전체 읽음 처리",
description = "해당 프로젝트의 활동만 읽음 처리한다. 다른 프로젝트에는 영향이 없다."
)
@PatchMapping("/read-all")
public ApiResponse<Void> markAllActivitiesAsRead(
@AuthenticationPrincipal Long currentUserId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,12 @@ public class ProjectController {

private final ProjectService projectService;

@Operation(summary = "프로젝트 목록 조회")
@Operation(
summary = "프로젝트 목록 조회",
description = """
내가 참여 중인 프로젝트만 반환한다. 내가 고정한 프로젝트가 먼저 오고, 나머지는 최근에 만들어진 순이다.
cursor 기반 페이지네이션이며 size 는 기본 20, 최대 50이다."""
)
@GetMapping
public ApiResponse<ProjectListResponse> getProjects(
@AuthenticationPrincipal Long currentUserId,
Expand All @@ -48,7 +53,12 @@ public ApiResponse<ProjectListResponse> getProjects(
return ApiResponse.success(CommonSuccessCode.OK, response);
}

@Operation(summary = "프로젝트 생성")
@Operation(
summary = "프로젝트 생성",
description = """
만든 사람이 ADMIN 역할의 멤버로 함께 등록된다.
한 사람이 가질 수 있는 프로젝트는 5개까지이며, 삭제한 프로젝트는 개수에 포함되지 않는다."""
)
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ApiResponse<ProjectResponse> createProject(
Expand All @@ -60,7 +70,10 @@ public ApiResponse<ProjectResponse> createProject(
return ApiResponse.success(CommonSuccessCode.CREATED, response);
}

@Operation(summary = "프로젝트 상세 조회")
@Operation(
summary = "프로젝트 상세 조회",
description = "프로젝트 멤버만 조회할 수 있다."
)
@GetMapping("/{projectId}")
public ApiResponse<ProjectDetailResponse> getProject(
@AuthenticationPrincipal Long currentUserId,
Expand All @@ -74,6 +87,9 @@ public ApiResponse<ProjectDetailResponse> getProject(
@Operation(
summary = "프로젝트 수정",
description = """
ADMIN 만 수정할 수 있다.
보내지 않은 필드는 null 로 덮어써지므로, 바꾸지 않을 값도 함께 보내야 한다.

`status` 를 `COMPLETED` 로 바꾸면 참여 중인 멤버 전원의 포트폴리오에 이 프로젝트가 생성된다.
프로젝트명·유형·개인외주 구분·설명·기간이 그대로 옮겨가고, 각자 맡은 역할이 함께 채워진다.
생성된 뒤에는 본인이 프로필에서 수정·삭제할 수 있다.
Expand All @@ -99,7 +115,10 @@ public ApiResponse<ProjectResponse> updateProject(
return ApiResponse.success(CommonSuccessCode.OK, response);
}

@Operation(summary = "프로젝트 삭제")
@Operation(
summary = "프로젝트 삭제",
description = "ADMIN 만 삭제할 수 있다. 실제로 지우지 않고 삭제 표시만 남긴다."
)
@DeleteMapping("/{projectId}")
public ApiResponse<Void> deleteProject(
@AuthenticationPrincipal Long currentUserId,
Expand All @@ -110,7 +129,12 @@ public ApiResponse<Void> deleteProject(
return ApiResponse.success(CommonSuccessCode.OK, null);
}

@Operation(summary = "프로젝트 고정")
@Operation(
summary = "프로젝트 고정",
description = """
고정은 호출한 사람에게만 적용되며 다른 멤버의 목록 순서에는 영향을 주지 않는다.
이미 고정한 프로젝트에 다시 호출해도 실패하지 않는다."""
)
@PostMapping("/{projectId}/pin")
public ApiResponse<ProjectPinResponse> pinProject(
@AuthenticationPrincipal Long currentUserId,
Expand All @@ -121,7 +145,10 @@ public ApiResponse<ProjectPinResponse> pinProject(
return ApiResponse.success(CommonSuccessCode.OK, response);
}

@Operation(summary = "프로젝트 고정 해제")
@Operation(
summary = "프로젝트 고정 해제",
description = "고정하지 않은 프로젝트에 호출해도 실패하지 않는다."
)
@DeleteMapping("/{projectId}/pin")
public ApiResponse<ProjectPinResponse> unpinProject(
@AuthenticationPrincipal Long currentUserId,
Expand Down
Loading
Loading