From 69d5bc8526c7c741fccfa8fa9c45c44a01f7738a Mon Sep 17 00:00:00 2001 From: khyun9807 Date: Sat, 7 Feb 2026 22:24:28 +0900 Subject: [PATCH 1/3] with claude code --- CLAUDE.md | 108 ++++++++++++++++++ build.gradle | 3 + .../payper/server/auth/AuthController.java | 10 ++ .../server/auth/dto/request/JoinRequest.java | 13 --- .../server/auth/dto/request/LoginRequest.java | 3 + .../dto/response/LoginSuccessResponse.java | 3 + .../dto/response/ReissueSuccessResponse.java | 3 + .../comment/controller/CommentController.java | 32 ++++-- .../server/comment/dto/CommentRequest.java | 8 +- .../server/comment/dto/CommentResponse.java | 24 +++- .../server/global/config/SwaggerConfig.java | 32 ++++++ .../server/global/response/ApiResponse.java | 5 + .../server/global/response/ExceptionDto.java | 4 + .../server/global/response/FieldErrorDto.java | 4 + .../controller/MerchantController.java | 11 +- .../post/controller/PostController.java | 44 ++++--- .../payper/server/post/dto/PostRequest.java | 10 +- .../payper/server/post/dto/PostResponse.java | 61 +++++++--- 18 files changed, 317 insertions(+), 61 deletions(-) create mode 100644 CLAUDE.md delete mode 100644 src/main/java/com/payper/server/auth/dto/request/JoinRequest.java create mode 100644 src/main/java/com/payper/server/global/config/SwaggerConfig.java diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b4059a6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,108 @@ +# Payper Server v2 + +## Policy +- 이미 작성되어 있는 기존 코드(사용자 또는 외부에서 작성된 것으로 보이는 코드)는 사용자의 허가나 직접적인 명령이 있기 전까지 수정하지 않는다. +- 기존 코드의 수정이 필요하다고 판단되는 경우, 반드시 사용자에게 먼저 확인을 받는다. + +## Tech Stack +- **Framework**: Spring Boot 4.0.1 (Java 21) +- **ORM**: Spring Data JPA (Hibernate) + MySQL +- **Auth**: JWT (JJWT 0.12.6) + Kakao OAuth +- **Security**: Spring Security (Stateless, Bearer Token) +- **Build**: Gradle 9.2.1 +- **Dev Tools**: Lombok + +## Build & Run +```bash +./gradlew build # 빌드 +./gradlew bootRun # 로컬 실행 (MySQL localhost:3306/payper_v2 필요) +./gradlew test # 테스트 실행 +``` + +## Project Structure +``` +com.payper.server +├── auth/ # 인증 (OAuth, JWT 발급/재발급/로그아웃) +│ ├── jwt/ # JWT 엔티티, 유틸리티, 리포지토리 +│ └── util/ # OAuth 유틸리티 (Kakao) +├── comment/ # 댓글 도메인 (CRUD, 대댓글, 커서 페이지네이션) +├── post/ # 게시글 도메인 (CRUD, 오프셋 페이지네이션) +├── merchant/ # 가맹점 도메인 +├── favorite/ # 즐겨찾기 도메인 +├── user/ # 사용자 도메인 +├── security/ # Spring Security 설정, JWT 필터 +├── global/ # 공통 (BaseTimeEntity, ApiResponse, ErrorCode, 예외처리) +└── domain/test/ # 테스트 컨트롤러 +``` + +## Package Convention (Feature-based) +``` +[feature]/ +├── controller/ # REST 엔드포인트 +├── service/ # 비즈니스 로직 (@Service @Transactional) +├── repository/ # JPA Repository +├── entity/ # JPA 엔티티 +└── dto/ + ├── request/ # 요청 DTO (@Valid) + └── response/ # 응답 DTO +``` + +## Key Patterns + +### API Response +- 모든 응답은 `ApiResponse` 래퍼 사용 (`global/response/ApiResponse.java`) +- 에러는 `ErrorCode` enum 기반 (`global/response/ErrorCode.java`) +- 예외는 `ApiException` 또는 `AuthException` 사용 + +### Entity +- `BaseTimeEntity` 상속으로 `createdAt`, `updatedAt` 자동 관리 +- 엔티티 생성은 `static create()` 팩토리 메서드 사용 +- 모든 연관관계는 `FetchType.LAZY` + 필요 시 `join fetch` JPQL + +### Soft Delete +- Post, Comment에 적용: `isDeleted` + `deletedAt` 필드 +- 게시글 삭제 시 댓글도 소프트 딜리트 (cascade) +- 삭제된 댓글은 자식이 있으면 "[삭제된 댓글입니다]"로 표시 + +### Pagination +- **게시글**: 오프셋 기반 (`Page`, page/size 파라미터) +- **댓글**: 커서 기반 (`Slice`, cursorId/size 파라미터) + +### Auth Flow +1. 클라이언트 → `/auth/login` (oauthToken) +2. Kakao OAuth 검증 → User 조회/생성 +3. Access Token (헤더) + Refresh Token (HttpOnly 쿠키) 발급 +4. 인증 필요 API: `Authorization: Bearer ` +5. 토큰 만료 시: `/auth/reissue` (쿠키의 refresh token 사용) + +### Security +- 공개 API: GET `/api/v1/posts/**`, GET `/api/v1/comments/*/replies`, `/auth/**` +- 인증 필요: POST/PUT/DELETE `/api/v1/posts/**`, `/api/v1/comments/**` +- 관리자: `/admin/**` + +### Validation +- 입력: Jakarta Bean Validation (`@NotBlank`, `@Size` 등) +- 비즈니스: 서비스 레이어에서 권한/상태 검증 +- 에러 응답: `FieldErrorDto` 리스트 반환 + +### Transaction +- 서비스 클래스에 `@Transactional` 기본 적용 +- 독립 트랜잭션 필요 시 `REQUIRES_NEW` 사용 (댓글 일괄 삭제, 리프레시 토큰 삭제) + +## Main Entities +| Entity | 설명 | 특이사항 | +|--------|------|----------| +| User | 사용자 | UUID userIdentifier, soft deactivate (active 필드) | +| Post | 게시글 | soft delete, PostType(BENEFIT/QUESTION/ETC) | +| Comment | 댓글 | soft delete, 자기참조(대댓글), parentComment | +| Merchant | 가맹점 | Category와 연관 | +| Category | 카테고리 | 자기참조(계층 구조) | +| PostLike/PostBookmark/PostReport | 게시글 부가 | UK(post_id, user_id) | +| CommentLike | 댓글 좋아요 | UK(comment_id, user_id) | +| Favorite | 즐겨찾기 | UK(user_id, merchant_id) | +| RefreshTokenEntity | 리프레시 토큰 | 해시 저장, replay attack 방지 | + +## Profiles +- **default (dev)**: localhost MySQL, ddl-auto: update, show-sql: true +- **prod**: 환경변수(DB_URL, DB_USERNAME, DB_PASSWORD), show-sql: false +- **test**: H2 인메모리, ddl-auto: create-drop diff --git a/build.gradle b/build.gradle index f2cc0c2..6ba2ce2 100644 --- a/build.gradle +++ b/build.gradle @@ -36,6 +36,9 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' runtimeOnly 'com.mysql:mysql-connector-j' + // swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.6' + // security implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'io.jsonwebtoken:jjwt-api:0.12.6' diff --git a/src/main/java/com/payper/server/auth/AuthController.java b/src/main/java/com/payper/server/auth/AuthController.java index 85a5653..9ee9fa1 100644 --- a/src/main/java/com/payper/server/auth/AuthController.java +++ b/src/main/java/com/payper/server/auth/AuthController.java @@ -7,17 +7,23 @@ import com.payper.server.global.response.ApiResponse; import com.payper.server.user.entity.AuthType; import com.payper.server.user.entity.User; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +@Tag(name = "인증", description = "로그인, 토큰 재발급, 로그아웃 API") @RestController @RequiredArgsConstructor @RequestMapping("/auth") public class AuthController { private final AuthService authService; + @Operation(summary = "로그인", description = "OAuth 토큰으로 로그인 (미가입 시 자동 회원가입). Access Token은 응답 바디, Refresh Token은 HttpOnly 쿠키로 발급됩니다.", security = {}) @PostMapping("/login") //로그인 시도 -> 필요하면 가입 -> 로그인 public ResponseEntity> enroll( @RequestBody LoginRequest loginRequest, @@ -38,8 +44,10 @@ public ResponseEntity> enroll( } + @Operation(summary = "토큰 재발급", description = "Refresh Token(쿠키)으로 새로운 Access Token을 발급합니다.", security = {}) @PostMapping("/reissue") public ResponseEntity> reissue( + @Parameter(description = "Refresh Token (HttpOnly 쿠키로 자동 전송)") @CookieValue(required = false) String refreshToken, HttpServletResponse response ) { @@ -50,8 +58,10 @@ public ResponseEntity> reissue( ); } + @Operation(summary = "로그아웃", description = "Refresh Token을 무효화하고 쿠키를 삭제합니다.", security = {}) @PostMapping("/logout") public ResponseEntity> logout( + @Parameter(description = "Refresh Token (HttpOnly 쿠키로 자동 전송)") @CookieValue(required = false) String refreshToken, HttpServletResponse response ) { diff --git a/src/main/java/com/payper/server/auth/dto/request/JoinRequest.java b/src/main/java/com/payper/server/auth/dto/request/JoinRequest.java deleted file mode 100644 index 72b85d9..0000000 --- a/src/main/java/com/payper/server/auth/dto/request/JoinRequest.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.payper.server.auth.dto.request; - -import jakarta.validation.constraints.NotBlank; -import lombok.Getter; -import lombok.Setter; - -@Getter -@Setter -public class JoinRequest { - - @NotBlank(message="OAuth Provider가 제공한 oauth resource access token이 필요합니다.") - private String oauthToken; -} diff --git a/src/main/java/com/payper/server/auth/dto/request/LoginRequest.java b/src/main/java/com/payper/server/auth/dto/request/LoginRequest.java index 6c82b84..128a12f 100644 --- a/src/main/java/com/payper/server/auth/dto/request/LoginRequest.java +++ b/src/main/java/com/payper/server/auth/dto/request/LoginRequest.java @@ -1,13 +1,16 @@ package com.payper.server.auth.dto.request; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import lombok.Getter; import lombok.Setter; @Getter @Setter +@Schema(description = "로그인 요청") public class LoginRequest { + @Schema(description = "OAuth Provider가 제공한 access token", example = "kakao_oauth_token_example") @NotBlank(message="OAuth Provider가 제공한 oauth resource access token이 필요합니다.") private String oauthToken; } diff --git a/src/main/java/com/payper/server/auth/dto/response/LoginSuccessResponse.java b/src/main/java/com/payper/server/auth/dto/response/LoginSuccessResponse.java index 3b0d83d..3f221fa 100644 --- a/src/main/java/com/payper/server/auth/dto/response/LoginSuccessResponse.java +++ b/src/main/java/com/payper/server/auth/dto/response/LoginSuccessResponse.java @@ -1,5 +1,6 @@ package com.payper.server.auth.dto.response; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.Setter; @@ -7,6 +8,8 @@ @Getter @Setter @AllArgsConstructor +@Schema(description = "로그인 성공 응답") public class LoginSuccessResponse { + @Schema(description = "JWT Access Token", example = "eyJhbGciOiJIUzI1NiJ9...") private String accessToken; } diff --git a/src/main/java/com/payper/server/auth/dto/response/ReissueSuccessResponse.java b/src/main/java/com/payper/server/auth/dto/response/ReissueSuccessResponse.java index 469e4d6..72ac249 100644 --- a/src/main/java/com/payper/server/auth/dto/response/ReissueSuccessResponse.java +++ b/src/main/java/com/payper/server/auth/dto/response/ReissueSuccessResponse.java @@ -1,5 +1,6 @@ package com.payper.server.auth.dto.response; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.Setter; @@ -7,6 +8,8 @@ @Getter @Setter @AllArgsConstructor +@Schema(description = "토큰 재발급 성공 응답") public class ReissueSuccessResponse { + @Schema(description = "새로 발급된 JWT Access Token", example = "eyJhbGciOiJIUzI1NiJ9...") private String accessToken; } diff --git a/src/main/java/com/payper/server/comment/controller/CommentController.java b/src/main/java/com/payper/server/comment/controller/CommentController.java index fed250f..247d9b8 100644 --- a/src/main/java/com/payper/server/comment/controller/CommentController.java +++ b/src/main/java/com/payper/server/comment/controller/CommentController.java @@ -5,12 +5,17 @@ import com.payper.server.comment.service.CommentService; import com.payper.server.global.response.ApiResponse; import com.payper.server.security.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +@Tag(name = "댓글", description = "댓글 수정/삭제, 내 댓글 조회, 대댓글 조회 API") @RestController @RequestMapping("/api/v1/comments") @RequiredArgsConstructor @@ -21,10 +26,12 @@ public class CommentController { * 댓글 수정 * 작성자만 수정 가능 */ + @Operation(summary = "댓글 수정", description = "작성자만 수정 가능") + @SecurityRequirement(name = "bearerAuth") @PutMapping("/{commentId}") public ResponseEntity> updateComment( @AuthenticationPrincipal CustomUserDetails user, - @PathVariable Long commentId, + @Parameter(description = "댓글 ID", example = "1") @PathVariable Long commentId, @RequestBody @Valid CommentRequest.UpdateComment request ) { commentService.updateComment(user.getId(), commentId, request); @@ -37,10 +44,12 @@ public ResponseEntity> updateComment( * * 자식 댓글은 삭제하지 않음 */ + @Operation(summary = "댓글 삭제", description = "작성자만 삭제 가능. 자식 댓글은 삭제되지 않습니다.") + @SecurityRequirement(name = "bearerAuth") @DeleteMapping("/{commentId}") public ResponseEntity> deleteComment( @AuthenticationPrincipal CustomUserDetails user, - @PathVariable Long commentId) { + @Parameter(description = "댓글 ID", example = "1") @PathVariable Long commentId) { commentService.deleteComment(user.getId(), commentId); return ResponseEntity.ok(ApiResponse.ok()); @@ -48,16 +57,18 @@ public ResponseEntity> deleteComment( /** * 내가 쓴 댓글 조회 - * + * * 무한 스크롤 방식 - * + * * 정렬: 최신 순 */ + @Operation(summary = "내가 쓴 댓글 조회", description = "커서 기반 페이지네이션. 최신순 정렬. 삭제된 댓글은 제외됩니다.") + @SecurityRequirement(name = "bearerAuth") @GetMapping("/me") public ResponseEntity> getMyComments( @AuthenticationPrincipal CustomUserDetails user, - @RequestParam(required = false) Long cursorId, - @RequestParam(defaultValue = "20") int size + @Parameter(description = "마지막 조회 댓글 ID (첫 요청 시 생략)") @RequestParam(required = false) Long cursorId, + @Parameter(description = "조회 개수", example = "20") @RequestParam(defaultValue = "20") int size ) { CommentResponse.MyCommentList response = commentService.getMyComments(user.getId(), cursorId, size); return ResponseEntity.ok(ApiResponse.ok(response)); @@ -66,13 +77,14 @@ public ResponseEntity> getMyComments( /** * 자식 댓글 조회 */ + @Operation(summary = "대댓글 조회", description = "부모 댓글의 대댓글을 커서 기반으로 조회합니다. 삭제된 댓글은 제외됩니다.", security = {}) @GetMapping("/{parentId}/replies") public ResponseEntity> getReplies( - @PathVariable Long parentId, - @RequestParam(required = false) Long cursorId, - @RequestParam(defaultValue = "20") int size + @Parameter(description = "부모 댓글 ID", example = "1") @PathVariable Long parentId, + @Parameter(description = "마지막 조회 댓글 ID (첫 요청 시 생략)") @RequestParam(required = false) Long cursorId, + @Parameter(description = "조회 개수", example = "20") @RequestParam(defaultValue = "20") int size ) { CommentResponse.CommentList response = commentService.getReplies(parentId, cursorId, size); return ResponseEntity.ok(ApiResponse.ok(response)); } -} \ No newline at end of file +} diff --git a/src/main/java/com/payper/server/comment/dto/CommentRequest.java b/src/main/java/com/payper/server/comment/dto/CommentRequest.java index 0b193ad..426adf4 100644 --- a/src/main/java/com/payper/server/comment/dto/CommentRequest.java +++ b/src/main/java/com/payper/server/comment/dto/CommentRequest.java @@ -1,5 +1,6 @@ package com.payper.server.comment.dto; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.annotation.Nullable; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; @@ -9,11 +10,14 @@ public class CommentRequest { /** * 댓글 작성 DTO */ + @Schema(description = "댓글 작성 요청") public record CreateComment( + @Schema(description = "댓글 내용", example = "좋은 글이네요!") @NotBlank(message = "댓글을 적어주세요.") @Size(max = 21800, message = "댓글은 21,800자 이하여야 합니다.") String content, + @Schema(description = "부모 댓글 ID (대댓글 작성 시)", example = "1", nullable = true) @Nullable Long parentCommentId ) {} @@ -21,9 +25,11 @@ public record CreateComment( /** * 댓글 수정 DTO */ + @Schema(description = "댓글 수정 요청") public record UpdateComment( + @Schema(description = "수정할 댓글 내용", example = "수정된 댓글입니다") @NotBlank(message = "댓글을 적어주세요.") @Size(max = 21800, message = "댓글은 21,800자 이하여야 합니다.") String content ) {} -} \ No newline at end of file +} diff --git a/src/main/java/com/payper/server/comment/dto/CommentResponse.java b/src/main/java/com/payper/server/comment/dto/CommentResponse.java index 3a83871..1f1d4b3 100644 --- a/src/main/java/com/payper/server/comment/dto/CommentResponse.java +++ b/src/main/java/com/payper/server/comment/dto/CommentResponse.java @@ -1,6 +1,7 @@ package com.payper.server.comment.dto; import com.payper.server.comment.entity.Comment; +import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDateTime; import java.util.List; @@ -10,9 +11,13 @@ public class CommentResponse { /** * Post에 달린 Comment 리스트 */ + @Schema(description = "댓글 목록 (커서 기반 페이지네이션)") public record CommentList( + @Schema(description = "댓글 목록") List comments, + @Schema(description = "다음 페이지 커서 (다음 페이지가 없으면 null)", example = "25") Long nextCursor, + @Schema(description = "다음 페이지 존재 여부", example = "true") boolean hasNext ) { public static CommentList from(List comments, Long nextCursor, boolean hasNext) { @@ -29,12 +34,19 @@ public static CommentList from(List comments, Long nextCursor, boolean /** * Comment Item */ + @Schema(description = "댓글 항목") public record CommentItem( + @Schema(description = "댓글 ID", example = "1") Long id, + @Schema(description = "작성자 이름", example = "홍길동") String userName, + @Schema(description = "부모 댓글 ID (최상위 댓글이면 null)", example = "1") Long parentCommentId, + @Schema(description = "댓글 내용 (삭제 시 '[삭제된 댓글입니다]')", example = "좋은 글이네요!") String content, + @Schema(description = "작성일시") LocalDateTime createdAt, + @Schema(description = "수정일시") LocalDateTime updatedAt ) { public static CommentItem from(Comment comment) { @@ -52,9 +64,13 @@ public static CommentItem from(Comment comment) { /** * 내가 작성한 Comment 리스트 */ + @Schema(description = "내가 작성한 댓글 목록 (커서 기반 페이지네이션)") public record MyCommentList( + @Schema(description = "댓글 목록") List comments, + @Schema(description = "다음 페이지 커서 (다음 페이지가 없으면 null)", example = "25") Long nextCursor, + @Schema(description = "다음 페이지 존재 여부", example = "true") boolean hasNext ) { public static MyCommentList from(List comments, Long nextCursor, boolean hasNext) { @@ -71,11 +87,17 @@ public static MyCommentList from(List comments, Long nextCursor, boolea /** * 내가 작성한 Comment Item */ + @Schema(description = "내가 작성한 댓글 항목") public record MyCommentItem( + @Schema(description = "댓글 ID", example = "1") Long id, + @Schema(description = "게시글 ID", example = "10") Long postId, + @Schema(description = "댓글 내용", example = "좋은 글이네요!") String content, + @Schema(description = "작성일시") LocalDateTime createdAt, + @Schema(description = "수정일시") LocalDateTime updatedAt ) { public static MyCommentItem from(Comment comment) { @@ -88,4 +110,4 @@ public static MyCommentItem from(Comment comment) { ); } } -} \ No newline at end of file +} diff --git a/src/main/java/com/payper/server/global/config/SwaggerConfig.java b/src/main/java/com/payper/server/global/config/SwaggerConfig.java new file mode 100644 index 0000000..f63cf15 --- /dev/null +++ b/src/main/java/com/payper/server/global/config/SwaggerConfig.java @@ -0,0 +1,32 @@ +package com.payper.server.global.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI openAPI() { + String securitySchemeName = "bearerAuth"; + + return new OpenAPI() + .info(new Info() + .title("Payper API") + .version("v1") + .description("Payper 서버 API 문서")) + .addSecurityItem(new SecurityRequirement().addList(securitySchemeName)) + .components(new Components() + .addSecuritySchemes(securitySchemeName, + new SecurityScheme() + .name(securitySchemeName) + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT"))); + } +} diff --git a/src/main/java/com/payper/server/global/response/ApiResponse.java b/src/main/java/com/payper/server/global/response/ApiResponse.java index 1ccefa0..e4a3dd7 100644 --- a/src/main/java/com/payper/server/global/response/ApiResponse.java +++ b/src/main/java/com/payper/server/global/response/ApiResponse.java @@ -1,5 +1,6 @@ package com.payper.server.global.response; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.annotation.Nullable; import lombok.Builder; import lombok.Getter; @@ -7,9 +8,13 @@ @Getter @Builder +@Schema(description = "공통 API 응답") public class ApiResponse { + @Schema(description = "HTTP 상태 코드", example = "200") private final Integer status; + @Schema(description = "응답 데이터") private final T data; + @Schema(description = "에러 정보 (성공 시 null)") private final ExceptionDto error; public static ApiResponse ok() { diff --git a/src/main/java/com/payper/server/global/response/ExceptionDto.java b/src/main/java/com/payper/server/global/response/ExceptionDto.java index a01c907..fa4be1c 100644 --- a/src/main/java/com/payper/server/global/response/ExceptionDto.java +++ b/src/main/java/com/payper/server/global/response/ExceptionDto.java @@ -1,10 +1,14 @@ package com.payper.server.global.response; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Getter; @Getter +@Schema(description = "에러 정보") public class ExceptionDto { + @Schema(description = "에러 코드", example = "NOT_FOUND") private final String code; + @Schema(description = "에러 메시지", example = "해당 리소스를 찾을 수 없습니다.") private final String message; private ExceptionDto(ErrorCode errorCode) { diff --git a/src/main/java/com/payper/server/global/response/FieldErrorDto.java b/src/main/java/com/payper/server/global/response/FieldErrorDto.java index 3dad2d2..0322508 100644 --- a/src/main/java/com/payper/server/global/response/FieldErrorDto.java +++ b/src/main/java/com/payper/server/global/response/FieldErrorDto.java @@ -1,11 +1,15 @@ package com.payper.server.global.response; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Getter; @Getter @AllArgsConstructor +@Schema(description = "필드 유효성 검증 에러") public class FieldErrorDto { + @Schema(description = "에러 발생 필드명", example = "title") private final String field; + @Schema(description = "에러 메시지", example = "제목을 적어주세요.") private final String message; } diff --git a/src/main/java/com/payper/server/merchant/controller/MerchantController.java b/src/main/java/com/payper/server/merchant/controller/MerchantController.java index 565f1ed..95da062 100644 --- a/src/main/java/com/payper/server/merchant/controller/MerchantController.java +++ b/src/main/java/com/payper/server/merchant/controller/MerchantController.java @@ -4,12 +4,17 @@ import com.payper.server.post.dto.PostRequest; import com.payper.server.post.service.PostService; import com.payper.server.security.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +@Tag(name = "가맹점", description = "가맹점 관련 API") @RestController @RequestMapping("/api/v1/merchants") @RequiredArgsConstructor @@ -24,13 +29,15 @@ public class MerchantController { * * 가입된 사용자만 글을 작성할 수 있음 */ + @Operation(summary = "게시글 작성", description = "가맹점에 대한 게시글을 작성합니다.") + @SecurityRequirement(name = "bearerAuth") @PostMapping("/{merchantId}/posts") public ResponseEntity> createPost( @AuthenticationPrincipal CustomUserDetails user, - @PathVariable Long merchantId, + @Parameter(description = "가맹점 ID", example = "1") @PathVariable Long merchantId, @RequestBody @Valid PostRequest.CreatePost request ) { Long postId = postService.createPost(user.getId(), merchantId, request); return ResponseEntity.status(201).body(ApiResponse.created(postId)); } -} \ No newline at end of file +} diff --git a/src/main/java/com/payper/server/post/controller/PostController.java b/src/main/java/com/payper/server/post/controller/PostController.java index 61e6504..cd1d13d 100644 --- a/src/main/java/com/payper/server/post/controller/PostController.java +++ b/src/main/java/com/payper/server/post/controller/PostController.java @@ -10,6 +10,10 @@ import com.payper.server.post.entity.PostType; import com.payper.server.post.service.PostService; import com.payper.server.security.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -21,6 +25,7 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +@Tag(name = "게시글", description = "게시글 CRUD 및 댓글 작성/조회 API") @RestController @RequestMapping("/api/v1/posts") @RequiredArgsConstructor @@ -32,10 +37,12 @@ public class PostController { * 게시글 수정 * 작성자만 수정 가능 */ + @Operation(summary = "게시글 수정", description = "작성자만 수정 가능") + @SecurityRequirement(name = "bearerAuth") @PutMapping("/{postId}") public ResponseEntity> updatePost( @AuthenticationPrincipal CustomUserDetails user, - @PathVariable Long postId, + @Parameter(description = "게시글 ID", example = "1") @PathVariable Long postId, @RequestBody @Valid PostRequest.UpdatePost request ) { postService.updatePost(user.getId(), postId, request); @@ -46,10 +53,12 @@ public ResponseEntity> updatePost( * 게시글 삭제 * 작성자만 삭제 가능 */ + @Operation(summary = "게시글 삭제", description = "작성자만 삭제 가능 (소프트 삭제)") + @SecurityRequirement(name = "bearerAuth") @DeleteMapping("/{postId}") public ResponseEntity> deletePost( @AuthenticationPrincipal CustomUserDetails user, - @PathVariable Long postId + @Parameter(description = "게시글 ID", example = "1") @PathVariable Long postId ) { postService.deletePost(user.getId(), postId); return ResponseEntity.ok(ApiResponse.ok()); @@ -60,8 +69,11 @@ public ResponseEntity> deletePost( * * 삭제되지 않은 글만 조회함 */ + @Operation(summary = "게시글 상세 조회", description = "삭제되지 않은 게시글만 조회 가능", security = {}) @GetMapping("/{postId}") - public ResponseEntity> getPostDetail(@PathVariable Long postId) { + public ResponseEntity> getPostDetail( + @Parameter(description = "게시글 ID", example = "1") @PathVariable Long postId + ) { PostResponse.PostDetail response = postService.getPostDetail(postId); return ResponseEntity.ok(ApiResponse.ok(response)); } @@ -77,14 +89,15 @@ public ResponseEntity> getPostDetail(@PathV * * 페이지네이션 */ + @Operation(summary = "게시글 목록 조회", description = "가맹점/타입 필터링, 정렬, 오프셋 기반 페이지네이션을 지원합니다.", security = {}) @GetMapping() public ResponseEntity>> getPosts( - @RequestParam(required = false) Long merchantId, - @RequestParam(required = false) PostType type, - @RequestParam(defaultValue = "POSTING_DATE") PostSortType sort, - @RequestParam(defaultValue = "DESC") Sort.Direction direction, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size + @Parameter(description = "가맹점 ID 필터") @RequestParam(required = false) Long merchantId, + @Parameter(description = "게시글 타입 필터 (BENEFIT, QUESTION, ETC)") @RequestParam(required = false) PostType type, + @Parameter(description = "정렬 기준 (POSTING_DATE, COMMENT_COUNT, LIKE_COUNT, VIEW_COUNT)") @RequestParam(defaultValue = "POSTING_DATE") PostSortType sort, + @Parameter(description = "정렬 방향") @RequestParam(defaultValue = "DESC") Sort.Direction direction, + @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") @RequestParam(defaultValue = "0") int page, + @Parameter(description = "페이지 크기", example = "10") @RequestParam(defaultValue = "10") int size ) { Pageable pageable = PageRequest.of(page, size, sort.toSort(direction)); Page response = postService.getPosts(merchantId, type, pageable); @@ -97,10 +110,12 @@ public ResponseEntity>> getPosts( * * 부모 댓글이 삭제되어도 대댓글 작성 허용 */ + @Operation(summary = "댓글 작성", description = "게시글에 댓글을 작성합니다. 대댓글은 parentCommentId를 지정합니다.") + @SecurityRequirement(name = "bearerAuth") @PostMapping("/{postId}/comments") public ResponseEntity> createComment( @AuthenticationPrincipal CustomUserDetails user, - @PathVariable Long postId, + @Parameter(description = "게시글 ID", example = "1") @PathVariable Long postId, @RequestBody @Valid CommentRequest.CreateComment request ) { Long commentId = commentService.createComment(user.getId(), postId, request); @@ -113,13 +128,14 @@ public ResponseEntity> createComment( * 부모 댓글로 페이지네이션 * 주의) 부모 댓글이 삭제되어도 자식 댓글이 남아있으면 [삭제된 댓글입니다]로 제공 */ + @Operation(summary = "게시글 댓글 조회", description = "부모 댓글 기준 커서 페이지네이션. 삭제된 부모 댓글도 자식이 있으면 '[삭제된 댓글입니다]'로 표시됩니다.", security = {}) @GetMapping("/{postId}/comments") public ResponseEntity> getPostComments( - @PathVariable Long postId, - @RequestParam(required = false) Long cursorId, - @RequestParam(defaultValue = "20") int size + @Parameter(description = "게시글 ID", example = "1") @PathVariable Long postId, + @Parameter(description = "마지막 조회 댓글 ID (첫 요청 시 생략)") @RequestParam(required = false) Long cursorId, + @Parameter(description = "조회 개수", example = "20") @RequestParam(defaultValue = "20") int size ) { CommentResponse.CommentList response = commentService.getPostComments(postId, cursorId, size); return ResponseEntity.ok(ApiResponse.ok(response)); } -} \ No newline at end of file +} diff --git a/src/main/java/com/payper/server/post/dto/PostRequest.java b/src/main/java/com/payper/server/post/dto/PostRequest.java index 1b87611..6460f9b 100644 --- a/src/main/java/com/payper/server/post/dto/PostRequest.java +++ b/src/main/java/com/payper/server/post/dto/PostRequest.java @@ -1,6 +1,7 @@ package com.payper.server.post.dto; import com.payper.server.post.entity.PostType; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; @@ -10,13 +11,17 @@ public class PostRequest { /** * 게시글 작성 DTO */ + @Schema(description = "게시글 작성 요청") public record CreatePost( + @Schema(description = "게시글 타입", example = "BENEFIT") @NotNull(message = "게시글의 타입을 선택해주세요.") PostType type, + @Schema(description = "게시글 제목", example = "맛있는 맛집 추천합니다") @NotBlank(message = "제목을 적어주세요.") String title, + @Schema(description = "게시글 내용", example = "여기 정말 맛있어요!") @NotBlank(message = "내용을 적어주세요.") @Size(max = 5500000, message = "내용은 500만자 이내로 적어주세요.") String content @@ -25,12 +30,15 @@ public record CreatePost( /** * 게시글 수정 DTO */ + @Schema(description = "게시글 수정 요청") public record UpdatePost( + @Schema(description = "수정할 제목", example = "수정된 제목입니다") @NotBlank(message = "제목을 적어주세요.") String title, + @Schema(description = "수정할 내용", example = "수정된 내용입니다") @NotBlank(message = "내용을 적어주세요.") @Size(max = 5500000, message = "내용은 500만자 이내로 적어주세요.") String content ) {} -} \ No newline at end of file +} diff --git a/src/main/java/com/payper/server/post/dto/PostResponse.java b/src/main/java/com/payper/server/post/dto/PostResponse.java index acffc28..7c8c9c5 100644 --- a/src/main/java/com/payper/server/post/dto/PostResponse.java +++ b/src/main/java/com/payper/server/post/dto/PostResponse.java @@ -2,6 +2,7 @@ import com.payper.server.post.entity.Post; import com.payper.server.post.entity.PostType; +import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDateTime; @@ -10,18 +11,30 @@ public class PostResponse { /** * 게시글 단일 조회 DTO */ + @Schema(description = "게시글 상세 응답") public record PostDetail( + @Schema(description = "게시글 ID", example = "1") Long id, - String authorName, /* 작성자 이름 */ - String merchantName, /* 가맹점명 */ - PostType type, /* 게시글 타입 */ - String title, /* 제목 */ - String content, /* 내용 */ - long commentCount, /* 댓글 수 */ - long viewCount, /* 조회 수 */ - long likeCount, /* 좋아요 수 */ - LocalDateTime createdAt, /* 글 작성 시간 */ - LocalDateTime updatedAt /* 글 수정 시간 */ + @Schema(description = "작성자 이름", example = "홍길동") + String authorName, + @Schema(description = "가맹점명", example = "스타벅스 강남점") + String merchantName, + @Schema(description = "게시글 타입", example = "BENEFIT") + PostType type, + @Schema(description = "제목", example = "맛있는 맛집 추천합니다") + String title, + @Schema(description = "내용", example = "여기 정말 맛있어요!") + String content, + @Schema(description = "댓글 수", example = "5") + long commentCount, + @Schema(description = "조회 수", example = "100") + long viewCount, + @Schema(description = "좋아요 수", example = "10") + long likeCount, + @Schema(description = "작성일시") + LocalDateTime createdAt, + @Schema(description = "수정일시") + LocalDateTime updatedAt ) { public static PostDetail from(Post post) { return new PostDetail( @@ -43,16 +56,26 @@ public static PostDetail from(Post post) { /** * 게시글 리스트 조회 DTO */ + @Schema(description = "게시글 목록 항목") public record PostList( + @Schema(description = "게시글 ID", example = "1") Long id, - String authorName, /* 작성자 이름 */ - String merchantName, /* 가맹점명 */ - PostType type, /* 게시글 타입 */ - String title, /* 제목 */ - long commentCount, /* 댓글 수 */ - long viewCount, /* 조회 수 */ - long likeCount, /* 좋아요 수 */ - LocalDateTime createdAt /* 글 작성 시간 */ + @Schema(description = "작성자 이름", example = "홍길동") + String authorName, + @Schema(description = "가맹점명", example = "스타벅스 강남점") + String merchantName, + @Schema(description = "게시글 타입", example = "BENEFIT") + PostType type, + @Schema(description = "제목", example = "맛있는 맛집 추천합니다") + String title, + @Schema(description = "댓글 수", example = "5") + long commentCount, + @Schema(description = "조회 수", example = "100") + long viewCount, + @Schema(description = "좋아요 수", example = "10") + long likeCount, + @Schema(description = "작성일시") + LocalDateTime createdAt ) { public static PostList from(Post post) { @@ -69,4 +92,4 @@ public static PostList from(Post post) { ); } } -} \ No newline at end of file +} From e36eb182a00e5baea49316a8b9f75b7f35a11657 Mon Sep 17 00:00:00 2001 From: khyun9807 Date: Sat, 7 Feb 2026 23:32:51 +0900 Subject: [PATCH 2/3] feedback1 --- src/main/java/com/payper/server/post/dto/PostRequest.java | 2 +- src/main/java/com/payper/server/post/dto/PostResponse.java | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/payper/server/post/dto/PostRequest.java b/src/main/java/com/payper/server/post/dto/PostRequest.java index 6460f9b..e5134d8 100644 --- a/src/main/java/com/payper/server/post/dto/PostRequest.java +++ b/src/main/java/com/payper/server/post/dto/PostRequest.java @@ -13,7 +13,7 @@ public class PostRequest { */ @Schema(description = "게시글 작성 요청") public record CreatePost( - @Schema(description = "게시글 타입", example = "BENEFIT") + @Schema(description = "게시글 타입 : BENEFIT|QUESTION|ETC ", example = "BENEFIT") @NotNull(message = "게시글의 타입을 선택해주세요.") PostType type, diff --git a/src/main/java/com/payper/server/post/dto/PostResponse.java b/src/main/java/com/payper/server/post/dto/PostResponse.java index 7c8c9c5..4f47bc6 100644 --- a/src/main/java/com/payper/server/post/dto/PostResponse.java +++ b/src/main/java/com/payper/server/post/dto/PostResponse.java @@ -17,7 +17,7 @@ public record PostDetail( Long id, @Schema(description = "작성자 이름", example = "홍길동") String authorName, - @Schema(description = "가맹점명", example = "스타벅스 강남점") + @Schema(description = "가맹점명", example = "스타벅스") String merchantName, @Schema(description = "게시글 타입", example = "BENEFIT") PostType type, @@ -62,7 +62,7 @@ public record PostList( Long id, @Schema(description = "작성자 이름", example = "홍길동") String authorName, - @Schema(description = "가맹점명", example = "스타벅스 강남점") + @Schema(description = "가맹점명", example = "스타벅스") String merchantName, @Schema(description = "게시글 타입", example = "BENEFIT") PostType type, From 1a02ea10ae66dadb2ddcc0ceefdff949d8ce3dfa Mon Sep 17 00:00:00 2001 From: khyun9807 Date: Sat, 7 Feb 2026 23:41:09 +0900 Subject: [PATCH 3/3] feedback2 --- .../java/com/payper/server/auth/AuthApi.java | 33 ++++++++++ .../payper/server/auth/AuthController.java | 12 +--- .../server/comment/controller/CommentApi.java | 45 +++++++++++++ .../comment/controller/CommentController.java | 28 +++----- .../merchant/controller/MerchantApi.java | 22 +++++++ .../controller/MerchantController.java | 11 +--- .../server/post/controller/PostApi.java | 66 +++++++++++++++++++ .../post/controller/PostController.java | 42 ++++-------- 8 files changed, 191 insertions(+), 68 deletions(-) create mode 100644 src/main/java/com/payper/server/auth/AuthApi.java create mode 100644 src/main/java/com/payper/server/comment/controller/CommentApi.java create mode 100644 src/main/java/com/payper/server/merchant/controller/MerchantApi.java create mode 100644 src/main/java/com/payper/server/post/controller/PostApi.java diff --git a/src/main/java/com/payper/server/auth/AuthApi.java b/src/main/java/com/payper/server/auth/AuthApi.java new file mode 100644 index 0000000..f0ed93f --- /dev/null +++ b/src/main/java/com/payper/server/auth/AuthApi.java @@ -0,0 +1,33 @@ +package com.payper.server.auth; + +import com.payper.server.auth.dto.request.LoginRequest; +import com.payper.server.auth.dto.response.LoginSuccessResponse; +import com.payper.server.auth.dto.response.ReissueSuccessResponse; +import com.payper.server.global.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.ResponseEntity; + +@Tag(name = "인증", description = "로그인, 토큰 재발급, 로그아웃 API") +public interface AuthApi { + + @Operation(summary = "로그인", description = "OAuth 토큰으로 로그인 (미가입 시 자동 회원가입). Access Token은 응답 바디, Refresh Token은 HttpOnly 쿠키로 발급됩니다.", security = {}) + ResponseEntity> enroll( + LoginRequest loginRequest, + HttpServletResponse response + ); + + @Operation(summary = "토큰 재발급", description = "Refresh Token(쿠키)으로 새로운 Access Token을 발급합니다.", security = {}) + ResponseEntity> reissue( + @Parameter(description = "Refresh Token (HttpOnly 쿠키로 자동 전송)") String refreshToken, + HttpServletResponse response + ); + + @Operation(summary = "로그아웃", description = "Refresh Token을 무효화하고 쿠키를 삭제합니다.", security = {}) + ResponseEntity> logout( + @Parameter(description = "Refresh Token (HttpOnly 쿠키로 자동 전송)") String refreshToken, + HttpServletResponse response + ); +} diff --git a/src/main/java/com/payper/server/auth/AuthController.java b/src/main/java/com/payper/server/auth/AuthController.java index 9ee9fa1..1b11c51 100644 --- a/src/main/java/com/payper/server/auth/AuthController.java +++ b/src/main/java/com/payper/server/auth/AuthController.java @@ -7,23 +7,17 @@ import com.payper.server.global.response.ApiResponse; import com.payper.server.user.entity.AuthType; import com.payper.server.user.entity.User; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.security.SecurityRequirement; -import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -@Tag(name = "인증", description = "로그인, 토큰 재발급, 로그아웃 API") @RestController @RequiredArgsConstructor @RequestMapping("/auth") -public class AuthController { +public class AuthController implements AuthApi { private final AuthService authService; - @Operation(summary = "로그인", description = "OAuth 토큰으로 로그인 (미가입 시 자동 회원가입). Access Token은 응답 바디, Refresh Token은 HttpOnly 쿠키로 발급됩니다.", security = {}) @PostMapping("/login") //로그인 시도 -> 필요하면 가입 -> 로그인 public ResponseEntity> enroll( @RequestBody LoginRequest loginRequest, @@ -44,10 +38,8 @@ public ResponseEntity> enroll( } - @Operation(summary = "토큰 재발급", description = "Refresh Token(쿠키)으로 새로운 Access Token을 발급합니다.", security = {}) @PostMapping("/reissue") public ResponseEntity> reissue( - @Parameter(description = "Refresh Token (HttpOnly 쿠키로 자동 전송)") @CookieValue(required = false) String refreshToken, HttpServletResponse response ) { @@ -58,10 +50,8 @@ public ResponseEntity> reissue( ); } - @Operation(summary = "로그아웃", description = "Refresh Token을 무효화하고 쿠키를 삭제합니다.", security = {}) @PostMapping("/logout") public ResponseEntity> logout( - @Parameter(description = "Refresh Token (HttpOnly 쿠키로 자동 전송)") @CookieValue(required = false) String refreshToken, HttpServletResponse response ) { diff --git a/src/main/java/com/payper/server/comment/controller/CommentApi.java b/src/main/java/com/payper/server/comment/controller/CommentApi.java new file mode 100644 index 0000000..8369083 --- /dev/null +++ b/src/main/java/com/payper/server/comment/controller/CommentApi.java @@ -0,0 +1,45 @@ +package com.payper.server.comment.controller; + +import com.payper.server.comment.dto.CommentRequest; +import com.payper.server.comment.dto.CommentResponse; +import com.payper.server.global.response.ApiResponse; +import com.payper.server.security.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; + +@Tag(name = "댓글", description = "댓글 수정/삭제, 내 댓글 조회, 대댓글 조회 API") +public interface CommentApi { + + @Operation(summary = "댓글 수정", description = "작성자만 수정 가능") + @SecurityRequirement(name = "bearerAuth") + ResponseEntity> updateComment( + CustomUserDetails user, + @Parameter(description = "댓글 ID", example = "1") Long commentId, + CommentRequest.UpdateComment request + ); + + @Operation(summary = "댓글 삭제", description = "작성자만 삭제 가능. 자식 댓글은 삭제되지 않습니다.") + @SecurityRequirement(name = "bearerAuth") + ResponseEntity> deleteComment( + CustomUserDetails user, + @Parameter(description = "댓글 ID", example = "1") Long commentId + ); + + @Operation(summary = "내가 쓴 댓글 조회", description = "커서 기반 페이지네이션. 최신순 정렬. 삭제된 댓글은 제외됩니다.") + @SecurityRequirement(name = "bearerAuth") + ResponseEntity> getMyComments( + CustomUserDetails user, + @Parameter(description = "마지막 조회 댓글 ID (첫 요청 시 생략)") Long cursorId, + @Parameter(description = "조회 개수", example = "20") int size + ); + + @Operation(summary = "대댓글 조회", description = "부모 댓글의 대댓글을 커서 기반으로 조회합니다. 삭제된 댓글은 제외됩니다.", security = {}) + ResponseEntity> getReplies( + @Parameter(description = "부모 댓글 ID", example = "1") Long parentId, + @Parameter(description = "마지막 조회 댓글 ID (첫 요청 시 생략)") Long cursorId, + @Parameter(description = "조회 개수", example = "20") int size + ); +} diff --git a/src/main/java/com/payper/server/comment/controller/CommentController.java b/src/main/java/com/payper/server/comment/controller/CommentController.java index 247d9b8..421e969 100644 --- a/src/main/java/com/payper/server/comment/controller/CommentController.java +++ b/src/main/java/com/payper/server/comment/controller/CommentController.java @@ -5,33 +5,26 @@ import com.payper.server.comment.service.CommentService; import com.payper.server.global.response.ApiResponse; import com.payper.server.security.CustomUserDetails; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.security.SecurityRequirement; -import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -@Tag(name = "댓글", description = "댓글 수정/삭제, 내 댓글 조회, 대댓글 조회 API") @RestController @RequestMapping("/api/v1/comments") @RequiredArgsConstructor -public class CommentController { +public class CommentController implements CommentApi { private final CommentService commentService; /** * 댓글 수정 * 작성자만 수정 가능 */ - @Operation(summary = "댓글 수정", description = "작성자만 수정 가능") - @SecurityRequirement(name = "bearerAuth") @PutMapping("/{commentId}") public ResponseEntity> updateComment( @AuthenticationPrincipal CustomUserDetails user, - @Parameter(description = "댓글 ID", example = "1") @PathVariable Long commentId, + @PathVariable Long commentId, @RequestBody @Valid CommentRequest.UpdateComment request ) { commentService.updateComment(user.getId(), commentId, request); @@ -44,12 +37,10 @@ public ResponseEntity> updateComment( * * 자식 댓글은 삭제하지 않음 */ - @Operation(summary = "댓글 삭제", description = "작성자만 삭제 가능. 자식 댓글은 삭제되지 않습니다.") - @SecurityRequirement(name = "bearerAuth") @DeleteMapping("/{commentId}") public ResponseEntity> deleteComment( @AuthenticationPrincipal CustomUserDetails user, - @Parameter(description = "댓글 ID", example = "1") @PathVariable Long commentId) { + @PathVariable Long commentId) { commentService.deleteComment(user.getId(), commentId); return ResponseEntity.ok(ApiResponse.ok()); @@ -62,13 +53,11 @@ public ResponseEntity> deleteComment( * * 정렬: 최신 순 */ - @Operation(summary = "내가 쓴 댓글 조회", description = "커서 기반 페이지네이션. 최신순 정렬. 삭제된 댓글은 제외됩니다.") - @SecurityRequirement(name = "bearerAuth") @GetMapping("/me") public ResponseEntity> getMyComments( @AuthenticationPrincipal CustomUserDetails user, - @Parameter(description = "마지막 조회 댓글 ID (첫 요청 시 생략)") @RequestParam(required = false) Long cursorId, - @Parameter(description = "조회 개수", example = "20") @RequestParam(defaultValue = "20") int size + @RequestParam(required = false) Long cursorId, + @RequestParam(defaultValue = "20") int size ) { CommentResponse.MyCommentList response = commentService.getMyComments(user.getId(), cursorId, size); return ResponseEntity.ok(ApiResponse.ok(response)); @@ -77,12 +66,11 @@ public ResponseEntity> getMyComments( /** * 자식 댓글 조회 */ - @Operation(summary = "대댓글 조회", description = "부모 댓글의 대댓글을 커서 기반으로 조회합니다. 삭제된 댓글은 제외됩니다.", security = {}) @GetMapping("/{parentId}/replies") public ResponseEntity> getReplies( - @Parameter(description = "부모 댓글 ID", example = "1") @PathVariable Long parentId, - @Parameter(description = "마지막 조회 댓글 ID (첫 요청 시 생략)") @RequestParam(required = false) Long cursorId, - @Parameter(description = "조회 개수", example = "20") @RequestParam(defaultValue = "20") int size + @PathVariable Long parentId, + @RequestParam(required = false) Long cursorId, + @RequestParam(defaultValue = "20") int size ) { CommentResponse.CommentList response = commentService.getReplies(parentId, cursorId, size); return ResponseEntity.ok(ApiResponse.ok(response)); diff --git a/src/main/java/com/payper/server/merchant/controller/MerchantApi.java b/src/main/java/com/payper/server/merchant/controller/MerchantApi.java new file mode 100644 index 0000000..a6a46ae --- /dev/null +++ b/src/main/java/com/payper/server/merchant/controller/MerchantApi.java @@ -0,0 +1,22 @@ +package com.payper.server.merchant.controller; + +import com.payper.server.global.response.ApiResponse; +import com.payper.server.post.dto.PostRequest; +import com.payper.server.security.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; + +@Tag(name = "가맹점", description = "가맹점 관련 API") +public interface MerchantApi { + + @Operation(summary = "게시글 작성", description = "가맹점에 대한 게시글을 작성합니다.") + @SecurityRequirement(name = "bearerAuth") + ResponseEntity> createPost( + CustomUserDetails user, + @Parameter(description = "가맹점 ID", example = "1") Long merchantId, + PostRequest.CreatePost request + ); +} diff --git a/src/main/java/com/payper/server/merchant/controller/MerchantController.java b/src/main/java/com/payper/server/merchant/controller/MerchantController.java index 95da062..cea6b29 100644 --- a/src/main/java/com/payper/server/merchant/controller/MerchantController.java +++ b/src/main/java/com/payper/server/merchant/controller/MerchantController.java @@ -4,21 +4,16 @@ import com.payper.server.post.dto.PostRequest; import com.payper.server.post.service.PostService; import com.payper.server.security.CustomUserDetails; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.security.SecurityRequirement; -import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -@Tag(name = "가맹점", description = "가맹점 관련 API") @RestController @RequestMapping("/api/v1/merchants") @RequiredArgsConstructor -public class MerchantController { +public class MerchantController implements MerchantApi { private final PostService postService; /** @@ -29,12 +24,10 @@ public class MerchantController { * * 가입된 사용자만 글을 작성할 수 있음 */ - @Operation(summary = "게시글 작성", description = "가맹점에 대한 게시글을 작성합니다.") - @SecurityRequirement(name = "bearerAuth") @PostMapping("/{merchantId}/posts") public ResponseEntity> createPost( @AuthenticationPrincipal CustomUserDetails user, - @Parameter(description = "가맹점 ID", example = "1") @PathVariable Long merchantId, + @PathVariable Long merchantId, @RequestBody @Valid PostRequest.CreatePost request ) { Long postId = postService.createPost(user.getId(), merchantId, request); diff --git a/src/main/java/com/payper/server/post/controller/PostApi.java b/src/main/java/com/payper/server/post/controller/PostApi.java new file mode 100644 index 0000000..fda53b7 --- /dev/null +++ b/src/main/java/com/payper/server/post/controller/PostApi.java @@ -0,0 +1,66 @@ +package com.payper.server.post.controller; + +import com.payper.server.comment.dto.CommentRequest; +import com.payper.server.comment.dto.CommentResponse; +import com.payper.server.global.response.ApiResponse; +import com.payper.server.post.dto.PostRequest; +import com.payper.server.post.dto.PostResponse; +import com.payper.server.post.dto.PostSortType; +import com.payper.server.post.entity.PostType; +import com.payper.server.security.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Sort; +import org.springframework.http.ResponseEntity; + +@Tag(name = "게시글", description = "게시글 CRUD 및 댓글 작성/조회 API") +public interface PostApi { + + @Operation(summary = "게시글 수정", description = "작성자만 수정 가능") + @SecurityRequirement(name = "bearerAuth") + ResponseEntity> updatePost( + CustomUserDetails user, + @Parameter(description = "게시글 ID", example = "1") Long postId, + PostRequest.UpdatePost request + ); + + @Operation(summary = "게시글 삭제", description = "작성자만 삭제 가능 (소프트 삭제)") + @SecurityRequirement(name = "bearerAuth") + ResponseEntity> deletePost( + CustomUserDetails user, + @Parameter(description = "게시글 ID", example = "1") Long postId + ); + + @Operation(summary = "게시글 상세 조회", description = "삭제되지 않은 게시글만 조회 가능", security = {}) + ResponseEntity> getPostDetail( + @Parameter(description = "게시글 ID", example = "1") Long postId + ); + + @Operation(summary = "게시글 목록 조회", description = "가맹점/타입 필터링, 정렬, 오프셋 기반 페이지네이션을 지원합니다.", security = {}) + ResponseEntity>> getPosts( + @Parameter(description = "가맹점 ID 필터") Long merchantId, + @Parameter(description = "게시글 타입 필터 (BENEFIT, QUESTION, ETC)") PostType type, + @Parameter(description = "정렬 기준 (POSTING_DATE, COMMENT_COUNT, LIKE_COUNT, VIEW_COUNT)") PostSortType sort, + @Parameter(description = "정렬 방향") Sort.Direction direction, + @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") int page, + @Parameter(description = "페이지 크기", example = "10") int size + ); + + @Operation(summary = "댓글 작성", description = "게시글에 댓글을 작성합니다. 대댓글은 parentCommentId를 지정합니다.") + @SecurityRequirement(name = "bearerAuth") + ResponseEntity> createComment( + CustomUserDetails user, + @Parameter(description = "게시글 ID", example = "1") Long postId, + CommentRequest.CreateComment request + ); + + @Operation(summary = "게시글 댓글 조회", description = "부모 댓글 기준 커서 페이지네이션. 삭제된 부모 댓글도 자식이 있으면 '[삭제된 댓글입니다]'로 표시됩니다.", security = {}) + ResponseEntity> getPostComments( + @Parameter(description = "게시글 ID", example = "1") Long postId, + @Parameter(description = "마지막 조회 댓글 ID (첫 요청 시 생략)") Long cursorId, + @Parameter(description = "조회 개수", example = "20") int size + ); +} diff --git a/src/main/java/com/payper/server/post/controller/PostController.java b/src/main/java/com/payper/server/post/controller/PostController.java index cd1d13d..287ebc7 100644 --- a/src/main/java/com/payper/server/post/controller/PostController.java +++ b/src/main/java/com/payper/server/post/controller/PostController.java @@ -10,10 +10,6 @@ import com.payper.server.post.entity.PostType; import com.payper.server.post.service.PostService; import com.payper.server.security.CustomUserDetails; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.security.SecurityRequirement; -import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -25,11 +21,10 @@ import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -@Tag(name = "게시글", description = "게시글 CRUD 및 댓글 작성/조회 API") @RestController @RequestMapping("/api/v1/posts") @RequiredArgsConstructor -public class PostController { +public class PostController implements PostApi { private final PostService postService; private final CommentService commentService; @@ -37,12 +32,10 @@ public class PostController { * 게시글 수정 * 작성자만 수정 가능 */ - @Operation(summary = "게시글 수정", description = "작성자만 수정 가능") - @SecurityRequirement(name = "bearerAuth") @PutMapping("/{postId}") public ResponseEntity> updatePost( @AuthenticationPrincipal CustomUserDetails user, - @Parameter(description = "게시글 ID", example = "1") @PathVariable Long postId, + @PathVariable Long postId, @RequestBody @Valid PostRequest.UpdatePost request ) { postService.updatePost(user.getId(), postId, request); @@ -53,12 +46,10 @@ public ResponseEntity> updatePost( * 게시글 삭제 * 작성자만 삭제 가능 */ - @Operation(summary = "게시글 삭제", description = "작성자만 삭제 가능 (소프트 삭제)") - @SecurityRequirement(name = "bearerAuth") @DeleteMapping("/{postId}") public ResponseEntity> deletePost( @AuthenticationPrincipal CustomUserDetails user, - @Parameter(description = "게시글 ID", example = "1") @PathVariable Long postId + @PathVariable Long postId ) { postService.deletePost(user.getId(), postId); return ResponseEntity.ok(ApiResponse.ok()); @@ -69,10 +60,9 @@ public ResponseEntity> deletePost( * * 삭제되지 않은 글만 조회함 */ - @Operation(summary = "게시글 상세 조회", description = "삭제되지 않은 게시글만 조회 가능", security = {}) @GetMapping("/{postId}") public ResponseEntity> getPostDetail( - @Parameter(description = "게시글 ID", example = "1") @PathVariable Long postId + @PathVariable Long postId ) { PostResponse.PostDetail response = postService.getPostDetail(postId); return ResponseEntity.ok(ApiResponse.ok(response)); @@ -89,15 +79,14 @@ public ResponseEntity> getPostDetail( * * 페이지네이션 */ - @Operation(summary = "게시글 목록 조회", description = "가맹점/타입 필터링, 정렬, 오프셋 기반 페이지네이션을 지원합니다.", security = {}) @GetMapping() public ResponseEntity>> getPosts( - @Parameter(description = "가맹점 ID 필터") @RequestParam(required = false) Long merchantId, - @Parameter(description = "게시글 타입 필터 (BENEFIT, QUESTION, ETC)") @RequestParam(required = false) PostType type, - @Parameter(description = "정렬 기준 (POSTING_DATE, COMMENT_COUNT, LIKE_COUNT, VIEW_COUNT)") @RequestParam(defaultValue = "POSTING_DATE") PostSortType sort, - @Parameter(description = "정렬 방향") @RequestParam(defaultValue = "DESC") Sort.Direction direction, - @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") @RequestParam(defaultValue = "0") int page, - @Parameter(description = "페이지 크기", example = "10") @RequestParam(defaultValue = "10") int size + @RequestParam(required = false) Long merchantId, + @RequestParam(required = false) PostType type, + @RequestParam(defaultValue = "POSTING_DATE") PostSortType sort, + @RequestParam(defaultValue = "DESC") Sort.Direction direction, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size ) { Pageable pageable = PageRequest.of(page, size, sort.toSort(direction)); Page response = postService.getPosts(merchantId, type, pageable); @@ -110,12 +99,10 @@ public ResponseEntity>> getPosts( * * 부모 댓글이 삭제되어도 대댓글 작성 허용 */ - @Operation(summary = "댓글 작성", description = "게시글에 댓글을 작성합니다. 대댓글은 parentCommentId를 지정합니다.") - @SecurityRequirement(name = "bearerAuth") @PostMapping("/{postId}/comments") public ResponseEntity> createComment( @AuthenticationPrincipal CustomUserDetails user, - @Parameter(description = "게시글 ID", example = "1") @PathVariable Long postId, + @PathVariable Long postId, @RequestBody @Valid CommentRequest.CreateComment request ) { Long commentId = commentService.createComment(user.getId(), postId, request); @@ -128,12 +115,11 @@ public ResponseEntity> createComment( * 부모 댓글로 페이지네이션 * 주의) 부모 댓글이 삭제되어도 자식 댓글이 남아있으면 [삭제된 댓글입니다]로 제공 */ - @Operation(summary = "게시글 댓글 조회", description = "부모 댓글 기준 커서 페이지네이션. 삭제된 부모 댓글도 자식이 있으면 '[삭제된 댓글입니다]'로 표시됩니다.", security = {}) @GetMapping("/{postId}/comments") public ResponseEntity> getPostComments( - @Parameter(description = "게시글 ID", example = "1") @PathVariable Long postId, - @Parameter(description = "마지막 조회 댓글 ID (첫 요청 시 생략)") @RequestParam(required = false) Long cursorId, - @Parameter(description = "조회 개수", example = "20") @RequestParam(defaultValue = "20") int size + @PathVariable Long postId, + @RequestParam(required = false) Long cursorId, + @RequestParam(defaultValue = "20") int size ) { CommentResponse.CommentList response = commentService.getPostComments(postId, cursorId, size); return ResponseEntity.ok(ApiResponse.ok(response));