Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,12 @@
import com.slatto.domain.auth.service.AuthService;
import com.slatto.domain.auth.service.EmailVerificationService;
import com.slatto.domain.auth.support.AuthCookieFactory;
import com.slatto.global.config.ApiErrorCodes;
import com.slatto.global.response.ApiResponse;
import com.slatto.global.response.code.CommonSuccessCode;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.security.SecurityRequirements;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
Expand Down Expand Up @@ -59,8 +61,10 @@ public class AuthController {
"""
)
@SecurityRequirements
@ResponseStatus(HttpStatus.FOUND)
@GetMapping("/login/google")
public ResponseEntity<Void> loginWithGoogle(
@Parameter(description = "로그인을 마친 뒤 돌아갈 프론트엔드 경로. 생략하면 기본 경로로 보냅니다.", example = "/projects")
@RequestParam(name = "redirectTo", required = false) String redirectTo
) {
AuthService.GoogleLoginEntry entry = authService.createGoogleLoginEntry(redirectTo);
Expand Down Expand Up @@ -105,6 +109,7 @@ public ResponseEntity<Void> handleGoogleCallback(
@SecurityRequirements
@PostMapping("/refresh")
public ApiResponse<AccessTokenResponse> reissueAccessToken(
@Parameter(description = "리프레시 토큰 쿠키. 로그인 시 서버가 HttpOnly 로 심어주므로 브라우저가 자동으로 보냅니다. 직접 넣을 값이 아닙니다.")
@CookieValue(name = "${app.cookie.refresh-token-name}", required = false) String refreshToken
) {
return ApiResponse.success(CommonSuccessCode.OK, authService.reissueAccessToken(refreshToken));
Expand All @@ -121,6 +126,8 @@ public ApiResponse<AccessTokenResponse> reissueAccessToken(
"""
)
@SecurityRequirements
@ResponseStatus(HttpStatus.CREATED)
@ApiErrorCodes({"AUTH_SIGNUP_DUPLICATE409", "AUTH_SIGNUP_SOCIAL409"})
@PostMapping("/signup")
public ResponseEntity<ApiResponse<EmailAuthResponse>> signup(
@Valid @RequestBody EmailSignupRequest request
Expand Down Expand Up @@ -170,6 +177,7 @@ public ResponseEntity<ApiResponse<EmailAuthResponse>> login(
)
@SecurityRequirements
@ResponseStatus(HttpStatus.CREATED)
@ApiErrorCodes({"AUTH_VERIFICATION_LIMIT429", "AUTH_VERIFICATION_RESEND429"})
@PostMapping("/email/verification-codes")
public ApiResponse<EmailVerificationSendResponse> sendEmailVerificationCode(
@Valid @RequestBody EmailVerificationSendRequest request
Expand Down Expand Up @@ -246,6 +254,7 @@ public ApiResponse<Void> resetPassword(@Valid @RequestBody PasswordResetRequest
@Operation(summary = "로그아웃", description = "서버에 저장된 리프레시 토큰을 무효화하고 쿠키를 삭제한다.")
@PostMapping("/logout")
public ResponseEntity<ApiResponse<Void>> logout(
@Parameter(description = "리프레시 토큰 쿠키. 브라우저가 자동으로 보냅니다. 직접 넣을 값이 아닙니다.")
@CookieValue(name = "${app.cookie.refresh-token-name}", required = false) String refreshToken
) {
authService.logout(refreshToken);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@
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.ApiErrorCodes;
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;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
Expand All @@ -20,20 +22,43 @@
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

@Tag(name = "Feedback", description = "피드백 API")
@Tag(
name = "Feedback",
description = """
피드백 API. 로그인 사용자와 공유 링크로 들어온 게스트가 함께 쓴다.
게스트로 호출하려면 ShareLink API 에서 먼저 게스트 등록을 마치고,
받은 sessionToken 을 X-Guest-Token 헤더에, guestId 를 파라미터나 본문에 실어 보낸다.
두 값의 짝이 맞지 않으면 SHARELINK403 으로 막힌다."""
)
@RestController
@RequestMapping("/api/v1")
@RequiredArgsConstructor
public class FeedbackController {

private final FeedbackService feedbackService;

@Operation(summary = "피드백 작성")
@Operation(
summary = "피드백 작성",
description = """
회원은 토큰으로, 게스트는 본문 `guestId` 와 `X-Guest-Token` 으로 식별한다.
**둘을 함께 보내거나 둘 다 보내지 않으면 400** 이다. 회원은 `guestId` 를 넣지 않는다.

회원은 이 영상이 속한 프로젝트의 활성 멤버여야 하고, 게스트는 자기 공유 링크의 영상에만 남길 수 있다.

`startTime` 과 `endTime` 은 영상 재생 지점(초)이다. 둘 다 생략하면 영상 전체에 대한 피드백이 되고,
함께 보내면 `startTime` 이 `endTime` 보다 클 수 없다.

작성되면 작성자를 뺀 프로젝트 멤버 전원에게 알림이 가고 최근 활동에 남는다.
"""
)
@OptionalAuthentication
@ResponseStatus(HttpStatus.CREATED)
@ApiErrorCodes({"PROJECT403", "SHARELINK403", "SHARELINK410"})
@PostMapping("/videos/{videoId}/feedbacks")
Comment on lines +55 to 57

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- candidate files ---'
git ls-files | rg '(^|/)(FeedbackController|FeedbackDetailController|OpenApiDocumentationTest)\.(java|kt)$|ApiErrorCodes'

printf '%s\n' '--- controller outlines ---'
for f in $(git ls-files | rg 'src/main/java/com/slatto/domain/feedback/controller/Feedback(Controller|DetailController)\.java$'); do
  echo "### $f"
  ast-grep outline "$f" 2>/dev/null || true
done

printf '%s\n' '--- relevant controller source ---'
for f in $(git ls-files | rg 'src/main/java/com/slatto/domain/feedback/controller/Feedback(Controller|DetailController)\.java$'); do
  echo "### $f"
  cat -n "$f"
done

printf '%s\n' '--- documentation test and annotation references ---'
rg -n -C 5 'OpenApiDocumentationTest|Operation\.description|description.*Operation|`@Operation`|`@Tag`|ApiErrorCodes' --glob '*.java' --glob '*.kt' .

Repository: SLAT-TO/SLATE-TO-BE

Length of output: 50376


🏁 Script executed:

#!/bin/bash
set -eu

rg -n -C 8 'OpenApiDocumentationTest|Operation\.description|`@Operation`|`@Tag`|ApiErrorCodes' --glob '*.java' --glob '*.kt' . | head -n 500

Repository: SLAT-TO/SLATE-TO-BE

Length of output: 50375


🏁 Script executed:

#!/bin/bash
set -eu

for f in $(git ls-files | rg 'src/main/java/com/slatto/domain/feedback/controller/Feedback(Controller|DetailController)\.java$'); do
  echo "### $f"
  cat -n "$f"
done

rg -n -C 8 'OpenApiDocumentationTest|Operation\.description|`@Operation`|`@Tag`|ApiErrorCodes' \
  --glob '*.java' --glob '*.kt' .

Repository: SLAT-TO/SLATE-TO-BE

Length of output: 50376


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- feedback operation annotations ---'
python3 - <<'PY'
from pathlib import Path
import re

files = [
    Path("src/main/java/com/slatto/domain/feedback/controller/FeedbackController.java"),
    Path("src/main/java/com/slatto/domain/feedback/controller/FeedbackDetailController.java"),
]
for path in files:
    text = path.read_text()
    print(f"### {path}")
    for match in re.finditer(r'`@Operation`\s*\((.*?)\)\s*(?:@\w+(?:\([^)]*\))?\s*)*@(?:Post|Patch|Delete|Get)Mapping\("([^"]+)"\)', text, re.S):
        args, mapping = match.groups()
        summary = re.search(r'\bsummary\s*=\s*"([^"]*)"', args)
        description = re.search(r'\bdescription\s*=', args)
        print({
            "mapping": mapping,
            "summary": summary.group(1) if summary else "",
            "has_operation_description": bool(description),
        })
PY

printf '%s\n' '--- exact documentation-test logic ---'
sed -n '60,110p' src/test/java/com/slatto/global/config/OpenApiDocumentationTest.java

Repository: SLAT-TO/SLATE-TO-BE

Length of output: 2844


8개 피드백·답글 작업에 @Operation(description = ...)을 추가하십시오. @Tag(description = ...)은 작업 설명을 대체하지 않습니다. OpenApiDocumentationTest는 설명이 없으면 실패합니다.

📍 Affects 2 files
  • src/main/java/com/slatto/domain/feedback/controller/FeedbackController.java#L42-L44 (this comment)
  • src/main/java/com/slatto/domain/feedback/controller/FeedbackController.java#L60-L62
  • src/main/java/com/slatto/domain/feedback/controller/FeedbackController.java#L76-L78
  • src/main/java/com/slatto/domain/feedback/controller/FeedbackController.java#L93-L95
  • src/main/java/com/slatto/domain/feedback/controller/FeedbackDetailController.java#L42-L45
  • src/main/java/com/slatto/domain/feedback/controller/FeedbackDetailController.java#L61-L63
  • src/main/java/com/slatto/domain/feedback/controller/FeedbackDetailController.java#L83-L85
  • src/main/java/com/slatto/domain/feedback/controller/FeedbackDetailController.java#L100-L102
🤖 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 `@src/main/java/com/slatto/domain/feedback/controller/FeedbackController.java`
around lines 42 - 44, FeedbackController and FeedbackDetailController의 8개 피드백·답글
엔드포인트에 각각 작업별 설명을 담은 `@Operation`(description = ...)을 추가하십시오.
FeedbackController.java의 42-44, 60-62, 76-78, 93-95행과
FeedbackDetailController.java의 42-45, 61-63, 83-85, 100-102행이 모두 변경 대상이며, 기존
`@Tag`(description = ...)만으로 대체하지 마십시오.

public ResponseEntity<ApiResponse<FeedbackCreateResDTO>> createFeedback(
@PathVariable Long videoId,
@AuthenticationPrincipal Long userId,
@Parameter(description = "게스트로 작성할 때만 보냅니다. 게스트 등록 응답의 sessionToken 값이며, 본문의 guestId 와 짝이 맞아야 합니다. 로그인 사용자는 생략합니다.")
@RequestHeader(value = "X-Guest-Token", required = false) String guestToken,
@Valid @RequestBody FeedbackCreateReqDTO request
) {
Expand All @@ -43,12 +68,25 @@ public ResponseEntity<ApiResponse<FeedbackCreateResDTO>> createFeedback(
.body(ApiResponse.success(CommonSuccessCode.CREATED, result));
}

@Operation(summary = "피드백 수정")
@Operation(
summary = "피드백 수정",
description = """
**작성자 본인만 수정할 수 있다.** 남의 피드백에 요청하면 `FEEDBACK403` 이다.
같은 프로젝트 멤버여도, 같은 공유 링크의 다른 게스트여도 마찬가지다.

전달한 항목만 부분 수정된다. `content`, `startTime`, `endTime` 모두 생략할 수 있다.
시간 검증은 수정을 반영한 뒤의 최종 값으로 한다. 한쪽만 보내도 기존 값과 묶여 `startTime` ≤ `endTime` 이어야 한다.

이미 삭제된 피드백은 404 다.
"""
)
@OptionalAuthentication
@ApiErrorCodes({"FEEDBACK403", "PROJECT403", "SHARELINK403", "SHARELINK410"})
@PatchMapping("/feedbacks/{feedbackId}")
public ResponseEntity<ApiResponse<FeedbackUpdateResDTO>> updateFeedback(
@PathVariable Long feedbackId,
@AuthenticationPrincipal Long userId,
@Parameter(description = "게스트로 수정할 때만 보냅니다. 게스트 등록 응답의 sessionToken 값이며, 본문의 guestId 와 짝이 맞아야 합니다. 로그인 사용자는 생략합니다.")
@RequestHeader(value = "X-Guest-Token", required = false) String guestToken,
@Valid @RequestBody FeedbackUpdateReqDTO request
) {
Expand All @@ -57,29 +95,60 @@ public ResponseEntity<ApiResponse<FeedbackUpdateResDTO>> updateFeedback(
.ok(ApiResponse.success(CommonSuccessCode.OK, result));
}

@Operation(summary = "피드백 삭제")
@Operation(
summary = "피드백 삭제",
description = """
**작성자 본인만 삭제할 수 있다.** 남의 피드백에 요청하면 `FEEDBACK403` 이다.

게스트는 `guestId` 를 쿼리 파라미터로, `X-Guest-Token` 을 헤더로 함께 보낸다.
회원은 둘 다 생략하고 토큰만 보낸다.

실제로 행을 지우지 않고 삭제 시각만 남긴다. 목록과 답글 조회에서 함께 빠진다.
이미 삭제된 피드백은 404 다.
"""
)
@OptionalAuthentication
@ApiErrorCodes({"FEEDBACK403", "PROJECT403", "SHARELINK403", "SHARELINK410"})
@DeleteMapping("/feedbacks/{feedbackId}")
public ResponseEntity<ApiResponse<Void>> deleteFeedback(
@PathVariable Long feedbackId,
@AuthenticationPrincipal Long userId,
@Parameter(description = "게스트로 삭제할 때만 보냅니다. 게스트 등록 응답의 guestId 값입니다. 로그인 사용자는 생략합니다.", example = "20")
@RequestParam(required = false) Long guestId,
@Parameter(description = "게스트로 삭제할 때만 보냅니다. 게스트 등록 응답의 sessionToken 값이며, guestId 와 짝이 맞아야 합니다. 로그인 사용자는 생략합니다.")
@RequestHeader(value = "X-Guest-Token", required = false) String guestToken
) {
feedbackService.deleteFeedback(feedbackId, userId, guestId, guestToken);
return ResponseEntity
.ok(ApiResponse.success(CommonSuccessCode.OK, null));
}

@Operation(summary = "피드백 목록 조회")
@Operation(
summary = "피드백 목록 조회",
description = """
**익명 조회는 막혀 있다.** 회원 토큰이나 게스트 자격(`guestId` + `X-Guest-Token`) 중 하나는 있어야 하고,
둘 다 없으면 `SHARELINK403` 이다. 인증이 선택이라는 것은 게스트도 볼 수 있다는 뜻이지 누구나 볼 수 있다는 뜻이 아니다.

재생 지점이 있는 피드백이 앞에 오고 그 안에서 `startTime` 오름차순, 같은 지점이면 등록순이다.
재생 지점이 없는 피드백은 모두 뒤로 밀린 뒤 등록순으로 붙는다.

커서는 직전 응답의 `nextCursor` 를 그대로 넣는다. 형식이 어긋나면 400 이다.
각 항목에는 답글 개수가 함께 담기고, 삭제된 피드백은 빠진다.
"""
)
@OptionalAuthentication
@ApiErrorCodes({"PROJECT403", "SHARELINK403", "SHARELINK410"})
@GetMapping("/videos/{videoId}/feedbacks")
public ResponseEntity<ApiResponse<FeedbackListResDTO>> getFeedbackList(
@PathVariable Long videoId,
@AuthenticationPrincipal Long userId,
@Parameter(description = "게스트로 조회할 때만 보냅니다. 게스트 등록 응답의 guestId 값입니다. 로그인 사용자는 생략합니다.", example = "20")
@RequestParam(required = false) Long guestId,
@Parameter(description = "게스트로 조회할 때만 보냅니다. 게스트 등록 응답의 sessionToken 값이며, guestId 와 짝이 맞아야 합니다. 로그인 사용자는 생략합니다.")
@RequestHeader(value = "X-Guest-Token", required = false) String guestToken,
@Parameter(description = "이전 응답의 nextCursor 를 그대로 넣습니다. {재생지점초}_{피드백ID} 형식이고 재생 지점이 없는 피드백은 앞이 n 입니다. 첫 페이지에서는 생략합니다.", example = "12_57")
@RequestParam(required = false) String cursor,
@Parameter(description = "조회 개수. 생략 시 10, 최대 50입니다.", example = "10")
@RequestParam(required = false) Integer size
) {
FeedbackListResDTO result = feedbackService.getFeedbackList(videoId, userId, guestId, guestToken, cursor, size);
Expand All @@ -91,6 +160,7 @@ public ResponseEntity<ApiResponse<FeedbackListResDTO>> getFeedbackList(
summary = "피드백 해결 상태 변경",
description = "활성 프로젝트 멤버만 변경할 수 있다. 다른 피드백 API 와 달리 게스트는 호출할 수 없다."
)
@ApiErrorCodes("PROJECT403")
@PatchMapping("/feedbacks/{feedbackId}/status")
public ResponseEntity<ApiResponse<FeedbackStatusResDTO>> changeFeedbackStatus(
@PathVariable Long feedbackId,
Expand Down
Loading
Loading