From 8baa1fbe71c633c50f8a5535ccfae5839bac2c2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A4=80=EC=98=81?= Date: Wed, 19 Aug 2026 22:37:04 +0900 Subject: [PATCH] =?UTF-8?q?T-14=20=ED=99=9C=EB=8F=99=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 활동 탭(EVENT/GAME/KOIN 등) CRUD + 순서 + 공개/숨김. 공개 목록 조회 API도 함께 넣었다 — 같은 리소스를 관리·공개 두 티켓으로 쪼갤 이유가 없다. - ActivityCategory — 최상위 엔티티라 soft delete(ADR-006) - GET/POST /v1/admin/activity-categories, PUT/DELETE /{id}, PATCH /{id}/publish, PATCH /order — 트랙 페이지(T-07)와 동일한 규약 - GET /v1/activity-categories — 공개, published만, display_order 순. SecurityConfig에 permitAll 추가 - "활동이 남아 있으면 삭제 거부"(AC-3.8)는 아직 구현하지 않았다 — Activity 엔티티가 없어 검증할 대상이 없다. T-15에서 Activity가 생기면 그때 deleteCategory에 추가한다 테스트: AdminActivityCategoryIntegrationTest(신규) — - 생성 후 공개 목록 노출, 개행 포함 headline 그대로 반환 - slug 중복 409 - 숨김 카테고리는 공개 목록에서 제외 - 순서 변경이 공개 목록에 즉시 반영 ./gradlew test 3회 연속 전체 통과(78개, 실패 0). Refs #26 --- .../controller/ActivityCategoryApi.java | 18 +++ .../ActivityCategoryController.java | 23 +++ .../controller/AdminActivityCategoryApi.java | 80 ++++++++++ .../AdminActivityCategoryController.java | 71 +++++++++ .../ActivityCategoryCreateRequest.java | 21 +++ .../ActivityCategoryUpdateRequest.java | 16 ++ .../response/ActivityCategoryResponse.java | 15 ++ .../AdminActivityCategoryResponse.java | 23 +++ .../exception/ActivityExceptionType.java | 2 + .../activity/model/ActivityCategory.java | 74 +++++++++ .../ActivityCategoryRepository.java | 14 ++ .../service/ActivityCategoryService.java | 22 +++ .../service/AdminActivityCategoryService.java | 83 ++++++++++ .../global/config/SecurityConfig.java | 1 + .../AdminActivityCategoryIntegrationTest.java | 145 ++++++++++++++++++ 15 files changed, 608 insertions(+) create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/ActivityCategoryApi.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/ActivityCategoryController.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/AdminActivityCategoryApi.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/AdminActivityCategoryController.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/request/ActivityCategoryCreateRequest.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/request/ActivityCategoryUpdateRequest.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/ActivityCategoryResponse.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/AdminActivityCategoryResponse.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/model/ActivityCategory.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/repository/ActivityCategoryRepository.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/service/ActivityCategoryService.java create mode 100644 src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/service/AdminActivityCategoryService.java create mode 100644 src/test/java/com/bcsdlab/bcsdinternalapiv2/activity/AdminActivityCategoryIntegrationTest.java diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/ActivityCategoryApi.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/ActivityCategoryApi.java new file mode 100644 index 0000000..7e3a62c --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/ActivityCategoryApi.java @@ -0,0 +1,18 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.controller; + +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.ActivityCategoryResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; + +@Tag(name = "활동 API (홈페이지 공개)") +public interface ActivityCategoryApi { + + @ApiResponses(value = { + @ApiResponse(responseCode = "200"), + }) + @Operation(summary = "공개 활동 카테고리 목록", description = "탭 노출 순서(display_order)대로 공개된 카테고리만 반환합니다.") + List getCategories(); +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/ActivityCategoryController.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/ActivityCategoryController.java new file mode 100644 index 0000000..5099e63 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/ActivityCategoryController.java @@ -0,0 +1,23 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.controller; + +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.ActivityCategoryResponse; +import com.bcsdlab.bcsdinternalapiv2.activity.service.ActivityCategoryService; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/activity-categories") +@RequiredArgsConstructor +public class ActivityCategoryController implements ActivityCategoryApi { + + private final ActivityCategoryService activityCategoryService; + + @Override + @GetMapping + public List getCategories() { + return activityCategoryService.getCategories(); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/AdminActivityCategoryApi.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/AdminActivityCategoryApi.java new file mode 100644 index 0000000..3397bf1 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/AdminActivityCategoryApi.java @@ -0,0 +1,80 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.controller; + +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request.ActivityCategoryCreateRequest; +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request.ActivityCategoryUpdateRequest; +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.AdminActivityCategoryResponse; +import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.OrderRequest; +import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.PublishRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.List; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "관리자 - 활동 카테고리 API") +@SecurityRequirement(name = "JWT") +public interface AdminActivityCategoryApi { + + @ApiResponses(value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + }) + @Operation(summary = "활동 카테고리 목록", description = "숨김 포함 전체, display_order 순.") + List getCategories(); + + @ApiResponses(value = { + @ApiResponse(responseCode = "201"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "409", content = @Content(schema = @Schema(hidden = true))), + }) + @Operation(summary = "활동 카테고리 생성") + ResponseEntity createCategory( + @RequestBody @Valid ActivityCategoryCreateRequest request); + + @ApiResponses(value = { + @ApiResponse(responseCode = "200"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + }) + @Operation(summary = "이름·헤드라인·히어로 이미지 수정", description = "slug는 불변이다.") + AdminActivityCategoryResponse updateCategory(@PathVariable Long id, + @RequestBody @Valid ActivityCategoryUpdateRequest request); + + @ApiResponses(value = { + @ApiResponse(responseCode = "204"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "409", content = @Content(schema = @Schema(hidden = true))), + }) + @Operation(summary = "활동 카테고리 삭제", description = "soft delete. 활동이 남아 있으면 409다(AC-3.8).") + ResponseEntity deleteCategory(@PathVariable Long id); + + @ApiResponses(value = { + @ApiResponse(responseCode = "204"), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + }) + @Operation(summary = "공개/숨김") + ResponseEntity publish(@PathVariable Long id, @RequestBody @Valid PublishRequest request); + + @ApiResponses(value = { + @ApiResponse(responseCode = "204"), + @ApiResponse(responseCode = "400", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "401", content = @Content(schema = @Schema(hidden = true))), + @ApiResponse(responseCode = "403", content = @Content(schema = @Schema(hidden = true))), + }) + @Operation(summary = "탭 순서 변경") + ResponseEntity reorder(@RequestBody @Valid OrderRequest request); +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/AdminActivityCategoryController.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/AdminActivityCategoryController.java new file mode 100644 index 0000000..5a8b24a --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/AdminActivityCategoryController.java @@ -0,0 +1,71 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.controller; + +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request.ActivityCategoryCreateRequest; +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request.ActivityCategoryUpdateRequest; +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.AdminActivityCategoryResponse; +import com.bcsdlab.bcsdinternalapiv2.activity.service.AdminActivityCategoryService; +import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.OrderRequest; +import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.PublishRequest; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/admin/activity-categories") +@RequiredArgsConstructor +public class AdminActivityCategoryController implements AdminActivityCategoryApi { + + private final AdminActivityCategoryService adminActivityCategoryService; + + @Override + @GetMapping + public List getCategories() { + return adminActivityCategoryService.getCategories(); + } + + @Override + @PostMapping + public ResponseEntity createCategory( + @Valid @RequestBody ActivityCategoryCreateRequest request) { + return ResponseEntity.status(HttpStatus.CREATED).body(adminActivityCategoryService.createCategory(request)); + } + + @Override + @PutMapping("/{id}") + public AdminActivityCategoryResponse updateCategory(@PathVariable Long id, + @Valid @RequestBody ActivityCategoryUpdateRequest request) { + return adminActivityCategoryService.updateCategory(id, request); + } + + @Override + @DeleteMapping("/{id}") + public ResponseEntity deleteCategory(@PathVariable Long id) { + adminActivityCategoryService.deleteCategory(id); + return ResponseEntity.noContent().build(); + } + + @Override + @PatchMapping("/{id}/publish") + public ResponseEntity publish(@PathVariable Long id, @Valid @RequestBody PublishRequest request) { + adminActivityCategoryService.publish(id, request); + return ResponseEntity.noContent().build(); + } + + @Override + @PatchMapping("/order") + public ResponseEntity reorder(@Valid @RequestBody OrderRequest request) { + adminActivityCategoryService.reorder(request); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/request/ActivityCategoryCreateRequest.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/request/ActivityCategoryCreateRequest.java new file mode 100644 index 0000000..b5f08ff --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/request/ActivityCategoryCreateRequest.java @@ -0,0 +1,21 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public record ActivityCategoryCreateRequest( + @NotBlank + @Pattern(regexp = "^[a-z0-9]+(-[a-z0-9]+)*$", message = "slug는 소문자·숫자·하이픈만 사용할 수 있습니다.") + String slug, + + @NotBlank + @Size(max = 30) + String name, + + @Size(max = 200) + String headline, + + String heroImageUrl +) { +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/request/ActivityCategoryUpdateRequest.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/request/ActivityCategoryUpdateRequest.java new file mode 100644 index 0000000..76359f1 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/request/ActivityCategoryUpdateRequest.java @@ -0,0 +1,16 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record ActivityCategoryUpdateRequest( + @NotBlank + @Size(max = 30) + String name, + + @Size(max = 200) + String headline, + + String heroImageUrl +) { +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/ActivityCategoryResponse.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/ActivityCategoryResponse.java new file mode 100644 index 0000000..d844856 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/ActivityCategoryResponse.java @@ -0,0 +1,15 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response; + +import com.bcsdlab.bcsdinternalapiv2.activity.model.ActivityCategory; + +public record ActivityCategoryResponse( + String slug, + String name, + String headline, + String heroImageUrl +) { + public static ActivityCategoryResponse from(ActivityCategory category) { + return new ActivityCategoryResponse( + category.getSlug(), category.getName(), category.getHeadline(), category.getHeroImageUrl()); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/AdminActivityCategoryResponse.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/AdminActivityCategoryResponse.java new file mode 100644 index 0000000..63332e2 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/AdminActivityCategoryResponse.java @@ -0,0 +1,23 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response; + +import com.bcsdlab.bcsdinternalapiv2.activity.model.ActivityCategory; + +/** + * 활동 건수(activity 잔존 여부 판단에 쓰는 그 수)는 아직 없다 — Activity가 생기는 + * T-15에서 이 레코드에 필드를 추가한다. + */ +public record AdminActivityCategoryResponse( + Long id, + String slug, + String name, + String headline, + String heroImageUrl, + boolean isPublished, + int displayOrder +) { + public static AdminActivityCategoryResponse from(ActivityCategory category) { + return new AdminActivityCategoryResponse( + category.getId(), category.getSlug(), category.getName(), category.getHeadline(), + category.getHeroImageUrl(), category.isPublished(), category.getDisplayOrder()); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/exception/ActivityExceptionType.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/exception/ActivityExceptionType.java index d4abe5d..7d8ee04 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/exception/ActivityExceptionType.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/exception/ActivityExceptionType.java @@ -6,6 +6,8 @@ public enum ActivityExceptionType implements BcsdExceptionType { ACTIVITY_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 활동입니다."), + CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 활동 카테고리입니다."), + CATEGORY_SLUG_DUPLICATED(HttpStatus.CONFLICT, "이미 사용 중인 카테고리 주소입니다."), ; private final HttpStatus status; diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/model/ActivityCategory.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/model/ActivityCategory.java new file mode 100644 index 0000000..7462349 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/model/ActivityCategory.java @@ -0,0 +1,74 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.model; + +import com.bcsdlab.bcsdinternalapiv2.global.SoftDeletableEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLRestriction; + +/** + * 활동 탭(EVENT/GAME/KOIN 등). 최상위 엔티티라 soft delete를 쓴다(ADR-006). + * 삭제 시 "활동이 남아 있으면 거부"(AC-3.8)는 Activity 엔티티가 생기는 T-15에서 + * 함께 구현한다 — 지금은 Activity가 없어 검증할 대상이 없다. + */ +@Getter +@Entity +@Table(name = "activity_category") +@SQLRestriction("deleted_at is null") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ActivityCategory extends SoftDeletableEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "slug", nullable = false, updatable = false) + private String slug; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "headline") + private String headline; + + @Column(name = "hero_image_url") + private String heroImageUrl; + + @Column(name = "display_order", nullable = false) + private int displayOrder; + + @Column(name = "is_published", nullable = false) + private boolean published; + + @Builder + private ActivityCategory(String slug, String name, String headline, String heroImageUrl, int displayOrder, + boolean published) { + this.slug = slug; + this.name = name; + this.headline = headline; + this.heroImageUrl = heroImageUrl; + this.displayOrder = displayOrder; + this.published = published; + } + + public void updateHeader(String name, String headline, String heroImageUrl) { + this.name = name; + this.headline = headline; + this.heroImageUrl = heroImageUrl; + } + + public void updatePublished(boolean published) { + this.published = published; + } + + public void updateDisplayOrder(int displayOrder) { + this.displayOrder = displayOrder; + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/repository/ActivityCategoryRepository.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/repository/ActivityCategoryRepository.java new file mode 100644 index 0000000..f8c9bb6 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/repository/ActivityCategoryRepository.java @@ -0,0 +1,14 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.repository; + +import com.bcsdlab.bcsdinternalapiv2.activity.model.ActivityCategory; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ActivityCategoryRepository extends JpaRepository { + + List findAllByOrderByDisplayOrderAsc(); + + List findAllByPublishedTrueOrderByDisplayOrderAsc(); + + boolean existsBySlug(String slug); +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/service/ActivityCategoryService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/service/ActivityCategoryService.java new file mode 100644 index 0000000..f031605 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/service/ActivityCategoryService.java @@ -0,0 +1,22 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.service; + +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.ActivityCategoryResponse; +import com.bcsdlab.bcsdinternalapiv2.activity.repository.ActivityCategoryRepository; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ActivityCategoryService { + + private final ActivityCategoryRepository activityCategoryRepository; + + public List getCategories() { + return activityCategoryRepository.findAllByPublishedTrueOrderByDisplayOrderAsc().stream() + .map(ActivityCategoryResponse::from) + .toList(); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/service/AdminActivityCategoryService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/service/AdminActivityCategoryService.java new file mode 100644 index 0000000..106130c --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/service/AdminActivityCategoryService.java @@ -0,0 +1,83 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.service; + +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request.ActivityCategoryCreateRequest; +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request.ActivityCategoryUpdateRequest; +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.AdminActivityCategoryResponse; +import com.bcsdlab.bcsdinternalapiv2.activity.exception.ActivityException; +import com.bcsdlab.bcsdinternalapiv2.activity.exception.ActivityExceptionType; +import com.bcsdlab.bcsdinternalapiv2.activity.model.ActivityCategory; +import com.bcsdlab.bcsdinternalapiv2.activity.repository.ActivityCategoryRepository; +import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.OrderRequest; +import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.PublishRequest; +import com.bcsdlab.bcsdinternalapiv2.global.util.DisplayOrders; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminActivityCategoryService { + + private final ActivityCategoryRepository activityCategoryRepository; + + public List getCategories() { + return activityCategoryRepository.findAllByOrderByDisplayOrderAsc().stream() + .map(AdminActivityCategoryResponse::from) + .toList(); + } + + @Transactional + public AdminActivityCategoryResponse createCategory(ActivityCategoryCreateRequest request) { + if (activityCategoryRepository.existsBySlug(request.slug())) { + throw new ActivityException(ActivityExceptionType.CATEGORY_SLUG_DUPLICATED); + } + + int displayOrder = activityCategoryRepository.findAllByOrderByDisplayOrderAsc().size(); + ActivityCategory category = activityCategoryRepository.save(ActivityCategory.builder() + .slug(request.slug()) + .name(request.name()) + .headline(request.headline()) + .heroImageUrl(request.heroImageUrl()) + .displayOrder(displayOrder) + .published(true) + .build()); + return AdminActivityCategoryResponse.from(category); + } + + @Transactional + public AdminActivityCategoryResponse updateCategory(Long id, ActivityCategoryUpdateRequest request) { + ActivityCategory category = findOrThrow(id); + category.updateHeader(request.name(), request.headline(), request.heroImageUrl()); + return AdminActivityCategoryResponse.from(category); + } + + @Transactional + public void deleteCategory(Long id) { + findOrThrow(id).delete(Instant.now()); + } + + @Transactional + public void publish(Long id, PublishRequest request) { + findOrThrow(id).updatePublished(request.isPublished()); + } + + @Transactional + public void reorder(OrderRequest request) { + List categories = activityCategoryRepository.findAllByOrderByDisplayOrderAsc(); + Map byId = categories.stream() + .collect(Collectors.toMap(ActivityCategory::getId, category -> category)); + + Map newOrders = DisplayOrders.reassign(request.ids(), byId.keySet()); + newOrders.forEach((id, order) -> byId.get(id).updateDisplayOrder(order)); + } + + private ActivityCategory findOrThrow(Long id) { + return activityCategoryRepository.findById(id) + .orElseThrow(() -> new ActivityException(ActivityExceptionType.CATEGORY_NOT_FOUND)); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/config/SecurityConfig.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/config/SecurityConfig.java index 8890f40..97717ac 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/config/SecurityConfig.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/global/config/SecurityConfig.java @@ -41,6 +41,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .permitAll() .requestMatchers("/v1/auth/password/**").permitAll() .requestMatchers("/v1/tracks/**").permitAll() + .requestMatchers("/v1/activity-categories/**").permitAll() .requestMatchers("/v1/members/me/initial-setup") .hasAnyAuthority("SCOPE_PRE_ACTIVATION", "SCOPE_FULL") .requestMatchers("/v1/admin/**").access(AuthorizationManagers.allOf( diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/activity/AdminActivityCategoryIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/activity/AdminActivityCategoryIntegrationTest.java new file mode 100644 index 0000000..72329e5 --- /dev/null +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/activity/AdminActivityCategoryIntegrationTest.java @@ -0,0 +1,145 @@ +package com.bcsdlab.bcsdinternalapiv2.activity; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.bcsdlab.bcsdinternalapiv2.IntegrationTestSupport; +import com.bcsdlab.bcsdinternalapiv2.activity.model.ActivityCategory; +import com.bcsdlab.bcsdinternalapiv2.activity.repository.ActivityCategoryRepository; +import com.bcsdlab.bcsdinternalapiv2.auth.repository.RefreshTokenRepository; +import com.bcsdlab.bcsdinternalapiv2.member.model.Member; +import com.bcsdlab.bcsdinternalapiv2.member.model.MemberRole; +import com.bcsdlab.bcsdinternalapiv2.member.model.MemberStatus; +import com.bcsdlab.bcsdinternalapiv2.member.model.MemberType; +import com.bcsdlab.bcsdinternalapiv2.member.repository.MemberRepository; +import com.bcsdlab.bcsdinternalapiv2.track.model.TrackMaster; +import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackMasterRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.security.crypto.password.PasswordEncoder; + +class AdminActivityCategoryIntegrationTest extends IntegrationTestSupport { + + private static final String RAW_PASSWORD = "Temp1234"; + + @Autowired + private ActivityCategoryRepository activityCategoryRepository; + + @Autowired + private TrackMasterRepository trackMasterRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private RefreshTokenRepository refreshTokenRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private String adminToken; + + @BeforeEach + void setUp() throws Exception { + activityCategoryRepository.deleteAll(); + refreshTokenRepository.deleteAll(); + memberRepository.deleteAll(); + + TrackMaster backend = trackMasterRepository.findByCode("BACKEND").orElseThrow(); + Member admin = memberRepository.save(Member.builder() + .studentNumber("20231111") + .password(passwordEncoder.encode(RAW_PASSWORD)) + .name("관리자") + .track(backend) + .generation("24-하") + .memberType(MemberType.REGULAR) + .university("OO대학교") + .email("admin@bcsd.club") + .status(MemberStatus.ACTIVE) + .build()); + String body = mockMvc.perform(post("/v1/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"studentNumber\":\"20231111\",\"password\":\"%s\",\"rememberMe\":false}" + .formatted(RAW_PASSWORD))) + .andReturn().getResponse().getContentAsString(); + adminToken = objectMapper.readTree(body).get("accessToken").asText(); + memberRepository.updateRole(admin.getId(), MemberRole.ADMIN); + } + + @Test + @DisplayName("카테고리를 생성하면 공개 목록에 나타난다") + void 카테고리_생성과_공개_목록_노출() throws Exception { + mockMvc.perform(post("/v1/admin/activity-categories") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"slug\":\"event\",\"name\":\"EVENT\",\"headline\":\"BCSD에서는\\n이런 활동을 하고 있어요.\"}")) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.slug").value("event")); + + mockMvc.perform(get("/v1/activity-categories")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].slug").value("event")) + .andExpect(jsonPath("$[0].headline").value("BCSD에서는\n이런 활동을 하고 있어요.")); + } + + @Test + @DisplayName("slug 중복은 409다") + void slug_중복은_409() throws Exception { + activityCategoryRepository.save(ActivityCategory.builder() + .slug("event").name("EVENT").displayOrder(0).published(true).build()); + + mockMvc.perform(post("/v1/admin/activity-categories") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"slug\":\"event\",\"name\":\"EVENT2\"}")) + .andExpect(status().isConflict()); + } + + @Test + @DisplayName("숨긴 카테고리는 공개 목록에서 제외된다") + void 숨긴_카테고리는_공개_목록에서_제외된다() throws Exception { + ActivityCategory event = activityCategoryRepository.save(ActivityCategory.builder() + .slug("event").name("EVENT").displayOrder(0).published(true).build()); + activityCategoryRepository.save(ActivityCategory.builder() + .slug("game").name("GAME").displayOrder(1).published(true).build()); + + mockMvc.perform(patch("/v1/admin/activity-categories/" + event.getId() + "/publish") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"isPublished\":false}")) + .andExpect(status().isNoContent()); + + mockMvc.perform(get("/v1/activity-categories")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)) + .andExpect(jsonPath("$[0].slug").value("game")); + } + + @Test + @DisplayName("탭 순서를 바꾸면 공개 목록에 즉시 반영된다") + void 순서_변경이_공개_목록에_반영된다() throws Exception { + ActivityCategory event = activityCategoryRepository.save(ActivityCategory.builder() + .slug("event").name("EVENT").displayOrder(0).published(true).build()); + ActivityCategory game = activityCategoryRepository.save(ActivityCategory.builder() + .slug("game").name("GAME").displayOrder(1).published(true).build()); + + mockMvc.perform(patch("/v1/admin/activity-categories/order") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"ids\":[%d,%d]}".formatted(game.getId(), event.getId()))) + .andExpect(status().isNoContent()); + + mockMvc.perform(get("/v1/activity-categories")) + .andExpect(jsonPath("$[0].slug").value("game")) + .andExpect(jsonPath("$[1].slug").value("event")); + } +}