diff --git a/src/main/java/com/payper/server/auth/AuthService.java b/src/main/java/com/payper/server/auth/AuthService.java index 6d4f473..268c644 100644 --- a/src/main/java/com/payper/server/auth/AuthService.java +++ b/src/main/java/com/payper/server/auth/AuthService.java @@ -35,7 +35,7 @@ public class AuthService { private final JwtRefreshTokenUtil jwtRefreshTokenUtil; private final JwtParseUtil jwtParseUtil; - public User findOrEnrollOAuthUser(OAuthUserInfo oauthUserInfo) { + private User findOrEnrollUser(OAuthUserInfo oauthUserInfo, UserRole userRole) { //먼저 검증 Optional user = userService.getActiveOAuthUser(oauthUserInfo); @@ -52,7 +52,7 @@ public User findOrEnrollOAuthUser(OAuthUserInfo oauthUserInfo) { AuthType.KAKAO, oauthUserInfo.getName(), oauthUserInfo.getOauthId(), - UserRole.USER, + userRole, // 매개변수 사용 true ) ); @@ -65,6 +65,14 @@ public User findOrEnrollOAuthUser(OAuthUserInfo oauthUserInfo) { ); } + public User findOrEnrollOAuthUser(OAuthUserInfo oauthUserInfo) { + return findOrEnrollUser(oauthUserInfo, UserRole.USER); + } + + public User findOrEnrollOAuthAdminUser(OAuthUserInfo oauthUserInfo) { + return findOrEnrollUser(oauthUserInfo, UserRole.ADMIN); + } + public OAuthUserInfo findOAuthUserInfo(String oauthToken, AuthType authType) { return switch (authType) { case AuthType.KAKAO -> kakaoOAuthUtil.getUserInfoFromOAuthToken(oauthToken); diff --git a/src/main/java/com/payper/server/auth/util/AuthDummyInit.java b/src/main/java/com/payper/server/auth/util/AuthDummyInit.java index a932ea2..faf625e 100644 --- a/src/main/java/com/payper/server/auth/util/AuthDummyInit.java +++ b/src/main/java/com/payper/server/auth/util/AuthDummyInit.java @@ -43,18 +43,26 @@ public void run(ApplicationArguments args) throws Exception { "7777", AuthType.KAKAO ); + OAuthUserInfo adminDummyOAuth1=new OAuthUserInfo( + "관리자", + "2222", + AuthType.KAKAO + ); User dummyUser1 = authService.findOrEnrollOAuthUser(dummyOAuth1); User dummyUser2 = authService.findOrEnrollOAuthUser(dummyOAuth2); User dummyUser3 = authService.findOrEnrollOAuthUser(dummyOAuth3); + User dummyUser4 = authService.findOrEnrollOAuthAdminUser(adminDummyOAuth1); String accessToken1 = authService.enrollNewAuthTokens(dummyUser1, null); String accessToken2 = authService.enrollNewAuthTokens(dummyUser2, null); String accessToken3 = authService.enrollNewAuthTokens(dummyUser3, null); + String accessToken4 = authService.enrollNewAuthTokens(dummyUser4, null); log.info("du1: name = {}, at = {}",dummyUser1.getName(),accessToken1); log.info("du2: name = {}, at = {}",dummyUser2.getName(),accessToken2); log.info("du3: name = {}, at = {}",dummyUser3.getName(),accessToken3); + log.info("admin du1: name = {}, at = {}",dummyUser4.getName(),accessToken4); } diff --git a/src/main/java/com/payper/server/comment/entity/Comment.java b/src/main/java/com/payper/server/comment/entity/Comment.java index 4194ba0..c1b6913 100644 --- a/src/main/java/com/payper/server/comment/entity/Comment.java +++ b/src/main/java/com/payper/server/comment/entity/Comment.java @@ -7,7 +7,6 @@ import lombok.*; import java.time.LocalDateTime; -import java.time.ZoneId; @Entity @Getter @@ -89,8 +88,8 @@ public static Comment create(Post post, User user, Comment parentComment, String .user(user) .parentComment(parentComment) .content(content) + .likeCount(0) .isDeleted(false) - .deletedAt(null) .build(); } @@ -100,4 +99,11 @@ public static Comment create(Post post, User user, Comment parentComment, String public void update(String content) { this.content = content; } + + /** + * 댓글 작성자인지 판단 + */ + public boolean isAuthor(Long userId) { + return this.user.getId().equals(userId); + } } \ No newline at end of file diff --git a/src/main/java/com/payper/server/comment/service/CommentService.java b/src/main/java/com/payper/server/comment/service/CommentService.java index 0a977d8..736c090 100644 --- a/src/main/java/com/payper/server/comment/service/CommentService.java +++ b/src/main/java/com/payper/server/comment/service/CommentService.java @@ -85,7 +85,7 @@ public void updateComment(Long userId, Long commentId, CommentRequest.UpdateComm .orElseThrow(() -> new ApiException(ErrorCode.COMMENT_NOT_FOUND)); // 댓글 수정 권한 조회 - if(!userId.equals(comment.getUser().getId())) { + if(!comment.isAuthor(userId)) { throw new ApiException(ErrorCode.NOT_COMMENT_AUTHOR); } @@ -105,7 +105,7 @@ public void deleteComment(Long userId, Long commentId) { .orElseThrow(() -> new ApiException(ErrorCode.COMMENT_NOT_FOUND)); // 댓글 삭제 권한 조회 - if(!userId.equals(comment.getUser().getId())) { + if(!comment.isAuthor(userId)) { throw new ApiException(ErrorCode.NOT_COMMENT_AUTHOR); } @@ -162,6 +162,9 @@ public CommentResponse.CommentList getPostComments(Long postId, Long cursorId, i return CommentResponse.CommentList.from(comments.getContent(), nextCursor, comments.hasNext()); } + /** + * 자식 댓글 조회 + */ @Transactional(readOnly = true) public CommentResponse.CommentList getReplies(Long parentId, Long cursorId, int size) { diff --git a/src/main/java/com/payper/server/global/response/ErrorCode.java b/src/main/java/com/payper/server/global/response/ErrorCode.java index 96649c2..1d32df0 100644 --- a/src/main/java/com/payper/server/global/response/ErrorCode.java +++ b/src/main/java/com/payper/server/global/response/ErrorCode.java @@ -35,6 +35,7 @@ public enum ErrorCode { // USER USER_NOT_FOUND("USER-001", HttpStatus.NOT_FOUND, "User Not Found"), + NOT_AN_ADMIN("USER-002", HttpStatus.FORBIDDEN, "Not An Admin"), // POST POST_NOT_FOUND("POST-001", HttpStatus.NOT_FOUND, "Post Not Found"), @@ -47,7 +48,15 @@ public enum ErrorCode { INVALID_PARENT_COMMENT("COMMENT-004", HttpStatus.BAD_REQUEST, "Invalid parent comment"), // MERCHANT - MERCHANT_NOT_FOUND("MERCHANT-001", HttpStatus.NOT_FOUND, "Merchant Not Found") + MERCHANT_NOT_FOUND("MERCHANT-001", HttpStatus.NOT_FOUND, "Merchant Not Found"), + MERCHANT_ALREADY_EXISTS("MERCHANT-002", HttpStatus.CONFLICT, "Merchant Already Exists"), + + // CATEGORY + CATEGORY_NOT_FOUND("CATEGORY-001", HttpStatus.NOT_FOUND, "Category Not Found"), + CATEGORY_ALREADY_EXISTS("CATEGORY-002", HttpStatus.CONFLICT, "Category Already Exists"), + CATEGORY_DEPTH_EXCEEDED("CATEGORY-003", HttpStatus.BAD_REQUEST, "Category Depth Exceeded"), + CATEGORY_CANNOT_BE_SELF_PARENT("CATEGORY-004", HttpStatus.BAD_REQUEST, "Category Cannot Be Self Parent"), + PARENT_CATEGORY_CANNOT_HAVE_PARENT("CATEGORY-005", HttpStatus.BAD_REQUEST, "Parent Category Cannot Be Changed To A Child"), ; diff --git a/src/main/java/com/payper/server/merchant/controller/CategoryApi.java b/src/main/java/com/payper/server/merchant/controller/CategoryApi.java new file mode 100644 index 0000000..fd2d90c --- /dev/null +++ b/src/main/java/com/payper/server/merchant/controller/CategoryApi.java @@ -0,0 +1,35 @@ +package com.payper.server.merchant.controller; + +import com.payper.server.global.response.ApiResponse; +import com.payper.server.merchant.dto.CategoryRequest; +import com.payper.server.merchant.dto.CategoryResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.parameters.RequestBody; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; + +import java.util.List; + +@Tag(name = "카테고리", description = "카테고리 등록/수정/조회 API") +public interface CategoryApi { + + @Operation(summary = "카테고리 등록", description = "관리자만 등록 가능. depth는 최대 2.") + @SecurityRequirement(name = "bearerAuth") + ResponseEntity> registerCategory( + @RequestBody CategoryRequest.RegisterCategory request + ); + + @Operation(summary = "카테고리 수정", description = "관리자만 수정 가능. 부모 카테고리는 이름만, 자식 카테고리는 이름과 부모 변경 가능.") + @SecurityRequirement(name = "bearerAuth") + ResponseEntity> updateCategory( + @Parameter(description = "카테고리 ID", required = true, example = "1") Long categoryId, + @RequestBody CategoryRequest.UpdateCategory request + ); + + @Operation(summary = "카테고리 조회", description = "부모 카테고리 ID로 필터링 가능. 파라미터 미지정 시 부모 카테고리만 조회. 카테고리명 오름차순 정렬.", security = {}) + ResponseEntity>> getCategories( + @Parameter(description = "부모 카테고리 ID 필터", required = false) Long parentCategoryId + ); +} \ No newline at end of file diff --git a/src/main/java/com/payper/server/merchant/controller/CategoryController.java b/src/main/java/com/payper/server/merchant/controller/CategoryController.java new file mode 100644 index 0000000..8f6c22f --- /dev/null +++ b/src/main/java/com/payper/server/merchant/controller/CategoryController.java @@ -0,0 +1,69 @@ +package com.payper.server.merchant.controller; + +import com.payper.server.global.response.ApiResponse; +import com.payper.server.merchant.dto.CategoryRequest; +import com.payper.server.merchant.dto.CategoryResponse; +import com.payper.server.merchant.service.CategoryService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/v1/categories") +@RequiredArgsConstructor +public class CategoryController implements CategoryApi { + private final CategoryService categoryService; + + /** + * 카테고리 등록 + * 관리자만 등록 가능 + * depth는 최대 2 + */ + @PreAuthorize("hasRole('ADMIN')") + @PostMapping() + public ResponseEntity> registerCategory( + @RequestBody @Valid CategoryRequest.RegisterCategory request + ) { + Long categoryId = categoryService.registerCategory(request); + return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.created(categoryId)); + } + + /** + * 카테고리 수정 (depth는 수정할 수 없음) + * 관리자만 수정 가능 + * 부모 카테고리 -> 이름만 변경 가능 + * 자식 카테고리 -> 이름, 부모 변경 가능 + */ + @PreAuthorize("hasRole('ADMIN')") + @PutMapping("/{categoryId}") + public ResponseEntity> updateCategory( + @PathVariable Long categoryId, + @RequestBody @Valid CategoryRequest.UpdateCategory request + ) { + categoryService.updateCategory(categoryId, request); + return ResponseEntity.ok(ApiResponse.ok()); + } + + /** + * 카테고리 조회 + * + * 필터링 조건 + * 부모 카테고리 + * 파라미터 안 넣으면 부모 카테고리만 보임 + * + * 정렬 조건 + * 카테고리명, 오름차순 + */ + @GetMapping() + public ResponseEntity>> getCategories( + @RequestParam(required = false) Long parentCategoryId + ) { + List response = categoryService.getCategories(parentCategoryId); + return ResponseEntity.ok(ApiResponse.ok(response)); + } +} \ No newline at end of file diff --git a/src/main/java/com/payper/server/merchant/controller/MerchantApi.java b/src/main/java/com/payper/server/merchant/controller/MerchantApi.java index a6a46ae..6b8e80b 100644 --- a/src/main/java/com/payper/server/merchant/controller/MerchantApi.java +++ b/src/main/java/com/payper/server/merchant/controller/MerchantApi.java @@ -1,22 +1,45 @@ package com.payper.server.merchant.controller; import com.payper.server.global.response.ApiResponse; +import com.payper.server.merchant.dto.MerchantRequest; +import com.payper.server.merchant.dto.MerchantResponse; 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.parameters.RequestBody; 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") +import java.util.List; + +@Tag(name = "가맹점", description = "가맹점 등록/수정/조회 및 게시글 작성 API") public interface MerchantApi { + @Operation(summary = "가맹점 등록", description = "관리자만 등록 가능. 카테고리를 선택하여 가맹점을 등록합니다.") + @SecurityRequirement(name = "bearerAuth") + ResponseEntity> registerMerchant( + @RequestBody MerchantRequest.RegisterMerchant request + ); + + @Operation(summary = "가맹점 수정", description = "관리자만 수정 가능") + @SecurityRequirement(name = "bearerAuth") + ResponseEntity> updateMerchant( + @Parameter(description = "가맹점 ID", required = true, example = "1") Long merchantId, + @RequestBody MerchantRequest.UpdateMerchant request + ); + + @Operation(summary = "가맹점 조회", description = "카테고리 ID로 필터링 가능. 가맹점명 오름차순 정렬.", security = {}) + ResponseEntity>> getMerchants( + @Parameter(description = "카테고리 ID 필터", required = false) Long categoryId + ); + @Operation(summary = "게시글 작성", description = "가맹점에 대한 게시글을 작성합니다.") @SecurityRequirement(name = "bearerAuth") ResponseEntity> createPost( CustomUserDetails user, - @Parameter(description = "가맹점 ID", example = "1") Long merchantId, - PostRequest.CreatePost request + @Parameter(description = "가맹점 ID", example = "1", required = true) Long merchantId, + @RequestBody 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 cea6b29..f1bb6f0 100644 --- a/src/main/java/com/payper/server/merchant/controller/MerchantController.java +++ b/src/main/java/com/payper/server/merchant/controller/MerchantController.java @@ -1,21 +1,75 @@ package com.payper.server.merchant.controller; import com.payper.server.global.response.ApiResponse; +import com.payper.server.merchant.dto.MerchantRequest; +import com.payper.server.merchant.dto.MerchantResponse; +import com.payper.server.merchant.service.MerchantService; import com.payper.server.post.dto.PostRequest; import com.payper.server.post.service.PostService; import com.payper.server.security.CustomUserDetails; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import java.util.List; + @RestController @RequestMapping("/api/v1/merchants") @RequiredArgsConstructor public class MerchantController implements MerchantApi { + private final MerchantService merchantService; private final PostService postService; + /** + * 가맹점 등록 + * + * 관리자만 등록 가능 + * 카테고리 리스트에서 카테고리를 선택해서 해당 카테고리의 id를 넘겨 받음 + */ + @PreAuthorize("hasRole('ADMIN')") + @PostMapping() + public ResponseEntity> registerMerchant( + @RequestBody @Valid MerchantRequest.RegisterMerchant request + ) { + Long merchantId = merchantService.registerMerchant(request); + return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.created(merchantId)); + } + + /** + * 가맹점 수정 + * 관리자만 수정 가능 + */ + @PreAuthorize("hasRole('ADMIN')") + @PutMapping("/{merchantId}") + public ResponseEntity> updateMerchant( + @PathVariable Long merchantId, + @RequestBody @Valid MerchantRequest.UpdateMerchant request + ) { + merchantService.updateMerchant(merchantId, request); + return ResponseEntity.ok(ApiResponse.ok()); + } + + /** + * 가맹점 조회 + * + * 필터링 조건 + * 카테고리 + * + * 정렬 조건 + * 가맹점명, 오름차순 + */ + @GetMapping() + public ResponseEntity>> getMerchants( + @RequestParam(required = false) Long categoryId + ) { + List response = merchantService.getMerchants(categoryId); + return ResponseEntity.ok(ApiResponse.ok(response)); + } + /** * 게시글 작성 * 가맹점에 대해 글을 작성함 @@ -31,6 +85,6 @@ public ResponseEntity> createPost( @RequestBody @Valid PostRequest.CreatePost request ) { Long postId = postService.createPost(user.getId(), merchantId, request); - return ResponseEntity.status(201).body(ApiResponse.created(postId)); + return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.created(postId)); } -} +} \ No newline at end of file diff --git a/src/main/java/com/payper/server/merchant/dto/CategoryRequest.java b/src/main/java/com/payper/server/merchant/dto/CategoryRequest.java new file mode 100644 index 0000000..12197a1 --- /dev/null +++ b/src/main/java/com/payper/server/merchant/dto/CategoryRequest.java @@ -0,0 +1,36 @@ +package com.payper.server.merchant.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.annotation.Nullable; +import jakarta.validation.constraints.NotBlank; + +public class CategoryRequest { + + /** + * 카테고리 등록 DTO + */ + @Schema(description = "카테고리 등록 요청") + public record RegisterCategory( + @Schema(description = "카테고리명", example = "카페") + @NotBlank(message = "카테고리명을 적어주세요. 예) 카페") + String name, + + @Schema(description = "부모 카테고리 ID (하위 카테고리 등록 시)", example = "1", nullable = true) + @Nullable + Long parentCategoryId + ) {} + + /** + * 카테고리 수정 DTO + */ + @Schema(description = "카테고리 수정 요청") + public record UpdateCategory( + @Schema(description = "카테고리명", example = "카페") + @NotBlank(message = "카테고리명을 적어주세요. 예) 카페") + String name, + + @Schema(description = "부모 카테고리 ID (하위 카테고리의 부모 변경 시)", example = "1", nullable = true) + @Nullable + Long parentCategoryId + ) {} +} \ No newline at end of file diff --git a/src/main/java/com/payper/server/merchant/dto/CategoryResponse.java b/src/main/java/com/payper/server/merchant/dto/CategoryResponse.java new file mode 100644 index 0000000..8b76364 --- /dev/null +++ b/src/main/java/com/payper/server/merchant/dto/CategoryResponse.java @@ -0,0 +1,23 @@ +package com.payper.server.merchant.dto; + +import com.payper.server.merchant.entity.Category; + +public class CategoryResponse { + + /** + * Category Item + */ + public record CategoryItem( + Long id, + String name, + Long parentCategoryId + ) { + public static CategoryResponse.CategoryItem from(Category category) { + return new CategoryResponse.CategoryItem( + category.getId(), + category.getName(), + category.getParentCategory() != null ? category.getParentCategory().getId() : null + ); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/payper/server/merchant/dto/MerchantRequest.java b/src/main/java/com/payper/server/merchant/dto/MerchantRequest.java new file mode 100644 index 0000000..9c3fe26 --- /dev/null +++ b/src/main/java/com/payper/server/merchant/dto/MerchantRequest.java @@ -0,0 +1,41 @@ +package com.payper.server.merchant.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.annotation.Nullable; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public class MerchantRequest { + + /** + * 가맹점 등록 DTO + */ + @Schema(description = "가맹점 등록 요청") + public record RegisterMerchant( + @Schema(description = "가맹점명", example = "스타벅스") + @NotBlank(message = "가맹점 명을 적어주세요. 예) 스타벅스") + String name, + + @Schema(description = "카테고리 ID", example = "1") + @NotNull(message = "카테고리 ID는 필수입니다.") + Long categoryId, + + @Schema(description = "가맹점 이미지 URL", example = "https://example.com/image.png", nullable = true) + @Nullable + String imageUrl + ) {} + + /** + * 가맹점 수정 DTO + */ + @Schema(description = "가맹점 수정 요청") + public record UpdateMerchant( + @Schema(description = "가맹점명", example = "스타벅스") + @NotBlank(message = "가맹점 명을 적어주세요. 예) 스타벅스") + String name, + + @Schema(description = "가맹점 이미지 URL", example = "https://example.com/image.png", nullable = true) + @Nullable + String imageUrl + ) {} +} \ No newline at end of file diff --git a/src/main/java/com/payper/server/merchant/dto/MerchantResponse.java b/src/main/java/com/payper/server/merchant/dto/MerchantResponse.java new file mode 100644 index 0000000..4a29e25 --- /dev/null +++ b/src/main/java/com/payper/server/merchant/dto/MerchantResponse.java @@ -0,0 +1,27 @@ +package com.payper.server.merchant.dto; + +import com.payper.server.merchant.entity.Merchant; + +public class MerchantResponse { + + /** + * Merchant Item + */ + public record MerchantItem( + Long id, + String name, + String imageUrl, + Long categoryId, + String categoryName + ) { + public static MerchantItem from(Merchant merchant) { + return new MerchantItem( + merchant.getId(), + merchant.getName(), + merchant.getImageUrl(), + merchant.getCategory().getId(), + merchant.getCategory().getName() + ); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/payper/server/merchant/entity/Category.java b/src/main/java/com/payper/server/merchant/entity/Category.java index dff038f..bb822c7 100644 --- a/src/main/java/com/payper/server/merchant/entity/Category.java +++ b/src/main/java/com/payper/server/merchant/entity/Category.java @@ -1,13 +1,11 @@ package com.payper.server.merchant.entity; import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; @Entity @Getter +@Builder @AllArgsConstructor(access = AccessLevel.PRIVATE) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Category { @@ -19,7 +17,7 @@ public class Category { /** * 카테고리명 */ - @Column(nullable = false) + @Column(nullable = false, unique = true) private String name; /** @@ -28,4 +26,43 @@ public class Category { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "parent_category_id") private Category parentCategory; + + /** + * 부모 카테고리인가? + */ + public boolean isRoot() { + return parentCategory == null; + } + + /** + * 자식 카테고리인가? + */ + public boolean isDepth2() { + return parentCategory != null; + } + + /** + * 카테고리 등록 + */ + public static Category register(String name, Category parentCategory) { + return Category.builder() + .name(name) + .parentCategory(parentCategory) + .build(); + } + + /** + * 이름 AND 부모 변경 + */ + public void updateNameAndParentCategory(String name, Category parentCategory) { + this.name = name; + this.parentCategory = parentCategory; + } + + /** + * 이름 변경 + */ + public void updateName(String name) { + this.name = name; + } } \ No newline at end of file diff --git a/src/main/java/com/payper/server/merchant/entity/Merchant.java b/src/main/java/com/payper/server/merchant/entity/Merchant.java index 7278424..a9e1742 100644 --- a/src/main/java/com/payper/server/merchant/entity/Merchant.java +++ b/src/main/java/com/payper/server/merchant/entity/Merchant.java @@ -1,13 +1,12 @@ package com.payper.server.merchant.entity; +import jakarta.annotation.Nullable; import jakarta.persistence.*; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; @Entity @Getter +@Builder @AllArgsConstructor(access = AccessLevel.PRIVATE) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Merchant { @@ -19,7 +18,7 @@ public class Merchant { /** * 가맹점명 */ - @Column(nullable = false) + @Column(nullable = false, unique = true) private String name; /** @@ -35,4 +34,25 @@ public class Merchant { */ @Column(name = "image_url") private String imageUrl; + + /** + * 가맹점 등록 + */ + public static Merchant register(String name, Category category, String imageUrl) { + return Merchant.builder() + .name(name) + .category(category) + .imageUrl(imageUrl) + .build(); + } + + /** + * 가맹점 수정 + */ + public void update(String name, @Nullable String imageUrl) { + this.name = name; + if (imageUrl != null) { + this.imageUrl = imageUrl; + } + } } \ No newline at end of file diff --git a/src/main/java/com/payper/server/merchant/repository/CategoryRepository.java b/src/main/java/com/payper/server/merchant/repository/CategoryRepository.java new file mode 100644 index 0000000..43b7b05 --- /dev/null +++ b/src/main/java/com/payper/server/merchant/repository/CategoryRepository.java @@ -0,0 +1,21 @@ +package com.payper.server.merchant.repository; + +import com.payper.server.merchant.entity.Category; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface CategoryRepository extends JpaRepository { + boolean existsByName(String name); + + boolean existsByNameAndIdNot(String name, Long id); + + boolean existsByIdAndParentCategoryIsNull(Long id); + + List findByParentCategoryIsNullOrderByNameAsc(); + + List findByParentCategoryIdOrderByNameAsc(Long parentCategoryId); + +} diff --git a/src/main/java/com/payper/server/merchant/repository/MerchantRepository.java b/src/main/java/com/payper/server/merchant/repository/MerchantRepository.java index b51d22e..63e7e8c 100644 --- a/src/main/java/com/payper/server/merchant/repository/MerchantRepository.java +++ b/src/main/java/com/payper/server/merchant/repository/MerchantRepository.java @@ -2,8 +2,23 @@ import com.payper.server.merchant.entity.Merchant; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.util.List; + @Repository public interface MerchantRepository extends JpaRepository { + boolean existsByName(String name); + + boolean existsByNameAndIdNot(String name, Long id); + + @Query(""" + select m from Merchant m + join fetch m.category + where (:categoryId IS NULL or m.category.id = :categoryId) + order by m.name asc + """) + List findMerchants(@Param("categoryId") Long categoryId); } diff --git a/src/main/java/com/payper/server/merchant/service/CategoryService.java b/src/main/java/com/payper/server/merchant/service/CategoryService.java new file mode 100644 index 0000000..70db1d4 --- /dev/null +++ b/src/main/java/com/payper/server/merchant/service/CategoryService.java @@ -0,0 +1,134 @@ +package com.payper.server.merchant.service; + +import com.payper.server.global.exception.ApiException; +import com.payper.server.global.response.ErrorCode; +import com.payper.server.merchant.dto.CategoryRequest; +import com.payper.server.merchant.dto.CategoryResponse; +import com.payper.server.merchant.entity.Category; +import com.payper.server.merchant.repository.CategoryRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CategoryService { + + private final CategoryRepository categoryRepository; + + /** + * 카테고리 등록 + */ + @Transactional + public Long registerCategory(CategoryRequest.RegisterCategory request) { + // 존재하는 카테고리명인지 체크 + if (categoryRepository.existsByName(request.name())) { + throw new ApiException(ErrorCode.CATEGORY_ALREADY_EXISTS); + } + + Category parentCategory = null; + if (request.parentCategoryId() != null) { + parentCategory = getValidatedParentCategory(request.parentCategoryId()); + + if (parentCategory.isDepth2()) { + throw new ApiException(ErrorCode.CATEGORY_DEPTH_EXCEEDED); + } + } + + // 카테고리 등록 + Category category = Category.register(request.name(), parentCategory); + + try { + categoryRepository.save(category); + } catch (DataIntegrityViolationException e) { + throw new ApiException(ErrorCode.CATEGORY_ALREADY_EXISTS); + } + + return category.getId(); + } + + // 요청 body로 받은 parent category id 검증 + private Category getValidatedParentCategory(Long parentCategoryId) { + // 부모 카테고리가 존재하는 카테고리인지 확인 + Category parentCategory = categoryRepository.findById(parentCategoryId) + .orElseThrow(() -> new ApiException(ErrorCode.CATEGORY_NOT_FOUND)); + + return parentCategory; + } + + /** + * 카테고리 수정 + */ + @Transactional + public void updateCategory(Long categoryId, CategoryRequest.UpdateCategory request) { + // 카테고리 조회 + Category category = categoryRepository.findById(categoryId) + .orElseThrow(() -> new ApiException(ErrorCode.CATEGORY_NOT_FOUND)); + + // 존재하는 카테고리명인지 체크 (나 제외) + if (categoryRepository.existsByNameAndIdNot(request.name(), categoryId)) { + throw new ApiException(ErrorCode.CATEGORY_ALREADY_EXISTS); + } + + boolean isParentCategory = category.isRoot(); + + // 부모 카테고리인 경우 + if (isParentCategory) { + // 부모는 자식이 될 수 없음 + if (request.parentCategoryId() != null) { + throw new ApiException(ErrorCode.PARENT_CATEGORY_CANNOT_HAVE_PARENT); + } + + category.updateName(request.name()); + return; + } + + // 자식 카테고리인 경우 1. 이름만 바꾸는 경우 + if (request.parentCategoryId() == null) { + category.updateName(request.name()); + return; + } + + // 자식 카테고리인 경우 2. 이름, 부모 모두 바꾸는 경우 + // 변경하고 싶은 부모 카테고리가 존재하는 카테고리인지 확인 + Category newParentCategory = categoryRepository.findById(request.parentCategoryId()) + .orElseThrow(() -> new ApiException(ErrorCode.CATEGORY_NOT_FOUND)); + + // 자기 자신 불가 + if (newParentCategory.getId().equals(categoryId)) { + throw new ApiException(ErrorCode.CATEGORY_CANNOT_BE_SELF_PARENT); + } + + // 부모는 반드시 root여야 함 + if (newParentCategory.isDepth2()) { + throw new ApiException(ErrorCode.CATEGORY_DEPTH_EXCEEDED); + } + + category.updateNameAndParentCategory(request.name(), newParentCategory); + } + + /** + * 카테고리 리스트 조회 + */ + @Transactional(readOnly = true) + public List getCategories(Long parentCategoryId) { + List categories; + if (parentCategoryId == null) { + categories = categoryRepository.findByParentCategoryIsNullOrderByNameAsc(); + } else { + if (!categoryRepository.existsByIdAndParentCategoryIsNull(parentCategoryId)) { + throw new ApiException(ErrorCode.CATEGORY_NOT_FOUND); + } + categories = categoryRepository.findByParentCategoryIdOrderByNameAsc(parentCategoryId); + } + + return categories.stream() + .map(CategoryResponse.CategoryItem::from) + .toList(); + } +} \ No newline at end of file diff --git a/src/main/java/com/payper/server/merchant/service/MerchantService.java b/src/main/java/com/payper/server/merchant/service/MerchantService.java new file mode 100644 index 0000000..70ab81a --- /dev/null +++ b/src/main/java/com/payper/server/merchant/service/MerchantService.java @@ -0,0 +1,88 @@ +package com.payper.server.merchant.service; + +import com.payper.server.global.exception.ApiException; +import com.payper.server.global.response.ErrorCode; +import com.payper.server.merchant.dto.MerchantRequest; +import com.payper.server.merchant.dto.MerchantResponse; +import com.payper.server.merchant.entity.Category; +import com.payper.server.merchant.entity.Merchant; +import com.payper.server.merchant.repository.CategoryRepository; +import com.payper.server.merchant.repository.MerchantRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class MerchantService { + + private final MerchantRepository merchantRepository; + private final CategoryRepository categoryRepository; + + /** + * 가맹점 등록 + */ + @Transactional + public Long registerMerchant(MerchantRequest.RegisterMerchant request) { + // 카테고리 조회 + Category category = categoryRepository.findById(request.categoryId()) + .orElseThrow(() -> new ApiException(ErrorCode.CATEGORY_NOT_FOUND)); + + // 존재하는 가맹점명인지 체크 + if (merchantRepository.existsByName(request.name())) { + throw new ApiException(ErrorCode.MERCHANT_ALREADY_EXISTS); + } + + // 가맹점 등록 + Merchant merchant = Merchant.register(request.name(), category, request.imageUrl()); + + try { + merchantRepository.save(merchant); + } catch (DataIntegrityViolationException e) { + throw new ApiException(ErrorCode.MERCHANT_ALREADY_EXISTS); + } + + return merchant.getId(); + } + + /** + * 가맹점 수정 + */ + @Transactional + public void updateMerchant(Long merchantId, MerchantRequest.UpdateMerchant request) { + // 가맹점 조회 + Merchant merchant = merchantRepository.findById(merchantId) + .orElseThrow(() -> new ApiException(ErrorCode.MERCHANT_NOT_FOUND)); + + // 존재하는 가맹점명인지 체크 (나 제외) + if (merchantRepository.existsByNameAndIdNot(request.name(), merchantId)) { + throw new ApiException(ErrorCode.MERCHANT_ALREADY_EXISTS); + } + + // 가맹점 수정 + merchant.update(request.name(), request.imageUrl()); + + log.info("가맹점 수정 완료 - merchantId: {}", merchant.getId()); + } + + /** + * 가맹점 리스트 조회 + */ + @Transactional(readOnly = true) + public List getMerchants(Long categoryId) { + if (categoryId != null && !categoryRepository.existsById(categoryId)) { + throw new ApiException(ErrorCode.CATEGORY_NOT_FOUND); + } + + List merchants = merchantRepository.findMerchants(categoryId); + + return merchants.stream() + .map(MerchantResponse.MerchantItem::from) + .toList(); + } +} \ No newline at end of file diff --git a/src/main/java/com/payper/server/post/entity/Post.java b/src/main/java/com/payper/server/post/entity/Post.java index 604abaa..2b79adb 100644 --- a/src/main/java/com/payper/server/post/entity/Post.java +++ b/src/main/java/com/payper/server/post/entity/Post.java @@ -7,7 +7,6 @@ import lombok.*; import java.time.LocalDateTime; -import java.time.ZoneId; @Entity @Getter @@ -120,14 +119,13 @@ public void decreaseCommentCount() { * 게시글 삭제 * soft delete */ - public void delete() { + public void softDelete() { if (this.isDeleted) { // 멱등성 고려 처리 return; } this.isDeleted = true; this.deletedAt = LocalDateTime.now(); - this.commentCount = 0; } /** @@ -149,6 +147,12 @@ public static Post create(User author, Merchant merchant, PostType type, String .type(type) .title(title) .content(content) + .commentCount(0) + .viewCount(0) + .likeCount(0) + .reportCount(0) + .isDeleted(false) + .isInactive(false) .build(); } @@ -160,6 +164,13 @@ public void update(String title, String content) { this.content = content; } + /** + * 게시글 작성자인지 판단 + */ + public boolean isAuthor(Long authorId) { + return this.author.getId().equals(authorId); + } + /** * 댓글을 달 수 있는 게시글인지 체크 */ diff --git a/src/main/java/com/payper/server/post/service/PostService.java b/src/main/java/com/payper/server/post/service/PostService.java index e48064e..8e5e57b 100644 --- a/src/main/java/com/payper/server/post/service/PostService.java +++ b/src/main/java/com/payper/server/post/service/PostService.java @@ -60,7 +60,7 @@ public void updatePost(Long userId, Long postId, PostRequest.UpdatePost request) .orElseThrow(() -> new ApiException(ErrorCode.POST_NOT_FOUND)); // 게시글 수정 권한 조회 - if(!userId.equals(post.getAuthor().getId())) { + if(!post.isAuthor(userId)) { throw new ApiException(ErrorCode.NOT_POST_AUTHOR); } @@ -76,16 +76,16 @@ public void updatePost(Long userId, Long postId, PostRequest.UpdatePost request) @Transactional public void deletePost(Long userId, Long postId) { // 게시글 조회 - Post post = postRepository.findById(postId) + Post post = postRepository.findByIdAndIsDeletedFalse(postId) .orElseThrow(() -> new ApiException(ErrorCode.POST_NOT_FOUND)); // 게시글 삭제 권한 조회 - if(!userId.equals(post.getAuthor().getId())) { + if(!post.isAuthor(userId)) { throw new ApiException(ErrorCode.NOT_POST_AUTHOR); } // 게시글 삭제 - post.delete(); + post.softDelete(); log.info("게시글 삭제 완료 - postId: {}", post.getId()); // 댓글 삭제 - 댓글 삭제에 실패해도 게시물 삭제는 진행되어야 하므로 try-catch로 묶음 diff --git a/src/main/java/com/payper/server/security/SecurityConfig.java b/src/main/java/com/payper/server/security/SecurityConfig.java index e228dd1..0e44954 100644 --- a/src/main/java/com/payper/server/security/SecurityConfig.java +++ b/src/main/java/com/payper/server/security/SecurityConfig.java @@ -8,6 +8,7 @@ import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.ProviderManager; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; @@ -22,10 +23,10 @@ import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import tools.jackson.databind.ObjectMapper; @Configuration @EnableWebSecurity +@EnableMethodSecurity @RequiredArgsConstructor public class SecurityConfig { private RequestMatcher permitAllRequestMatcher; @@ -50,7 +51,9 @@ void init() { requestMatcher.matcher(HttpMethod.GET, "/favicon.ico"), requestMatcher.matcher("/auth/**"), requestMatcher.matcher(HttpMethod.GET, "/api/v1/posts/**"), - requestMatcher.matcher(HttpMethod.GET, "/api/v1/comments/*/replies") + requestMatcher.matcher(HttpMethod.GET, "/api/v1/comments/*/replies"), + requestMatcher.matcher(HttpMethod.GET, "/api/v1/merchants/**"), + requestMatcher.matcher(HttpMethod.GET, "/api/v1/categories/**") ); // 인증이 필요한 요청 authenticatedRequestMatcher = new OrRequestMatcher( @@ -68,10 +71,14 @@ void init() { requestMatcher.matcher(HttpMethod.DELETE, "/api/v1/posts/**"), // 가맹점 관련 - requestMatcher.matcher(HttpMethod.POST, "/api/v1/merchants/**") + requestMatcher.matcher(HttpMethod.POST, "/api/v1/merchants/*/posts") ); adminRequestMatcher = new OrRequestMatcher( - requestMatcher.matcher(HttpMethod.GET, "/admin/**") + requestMatcher.matcher(HttpMethod.GET, "/admin/**"), + requestMatcher.matcher(HttpMethod.POST, "/api/v1/merchants"), + requestMatcher.matcher(HttpMethod.PUT, "/api/v1/merchants/**"), + requestMatcher.matcher(HttpMethod.POST, "/api/v1/categories"), + requestMatcher.matcher(HttpMethod.PUT, "/api/v1/categories/**") ); }