diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/AdminActivityApi.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/AdminActivityApi.java new file mode 100644 index 0000000..e0d655d --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/AdminActivityApi.java @@ -0,0 +1,110 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.controller; + +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request.ActivityCreateRequest; +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request.ActivityImagesReplaceRequest; +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request.ActivityUpdateRequest; +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.AdminActivityDetailResponse; +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.AdminActivitySummaryResponse; +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.Parameter; +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.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "관리자 - 활동 API") +@SecurityRequirement(name = "JWT") +public interface AdminActivityApi { + + @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 = "숨김 포함. categoryId/year/published로 선택적으로 필터링한다.") + Page getActivities(@RequestParam(required = false) Long categoryId, + @RequestParam(required = false) Integer year, + @RequestParam(required = false) Boolean published, + Pageable pageable); + + @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 = "활동 상세 조회") + AdminActivityDetailResponse getActivity(@PathVariable Long id); + + @ApiResponses(value = { + @ApiResponse(responseCode = "201"), + @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))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + }) + @Operation(summary = "활동 생성", description = "본문은 저장 전 정제된다(ADR-008). 생성 직후 공개 상태다.") + ResponseEntity createActivity(@RequestBody @Valid ActivityCreateRequest request); + + @ApiResponses(value = { + @ApiResponse(responseCode = "200"), + @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))), + @ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))), + }) + @Operation(summary = "활동 수정") + AdminActivityDetailResponse updateActivity(@PathVariable Long id, + @RequestBody @Valid ActivityUpdateRequest 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))), + }) + @Operation(summary = "활동 삭제", description = "soft delete — 데이터는 보존된다.") + ResponseEntity deleteActivity(@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(@Parameter(required = true) @RequestParam Long categoryId, + @Parameter(required = true) @RequestParam int year, + @Parameter(required = true) @RequestParam int month, + @RequestBody @Valid OrderRequest 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 = "첫 번째 항목이 목록 썸네일이다(INV-12).") + List replaceImages(@PathVariable Long id, @RequestBody @Valid ActivityImagesReplaceRequest request); +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/AdminActivityController.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/AdminActivityController.java new file mode 100644 index 0000000..ff8bd9c --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/AdminActivityController.java @@ -0,0 +1,93 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.controller; + +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request.ActivityCreateRequest; +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request.ActivityImagesReplaceRequest; +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request.ActivityUpdateRequest; +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.AdminActivityDetailResponse; +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.AdminActivitySummaryResponse; +import com.bcsdlab.bcsdinternalapiv2.activity.service.AdminActivityService; +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.data.domain.Page; +import org.springframework.data.domain.Pageable; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/admin/activities") +@RequiredArgsConstructor +public class AdminActivityController implements AdminActivityApi { + + private final AdminActivityService adminActivityService; + + @Override + @GetMapping + public Page getActivities(@RequestParam(required = false) Long categoryId, + @RequestParam(required = false) Integer year, + @RequestParam(required = false) Boolean published, + Pageable pageable) { + return adminActivityService.getActivities(categoryId, year, published, pageable); + } + + @Override + @GetMapping("/{id}") + public AdminActivityDetailResponse getActivity(@PathVariable Long id) { + return adminActivityService.getActivity(id); + } + + @Override + @PostMapping + public ResponseEntity createActivity( + @Valid @RequestBody ActivityCreateRequest request) { + return ResponseEntity.status(HttpStatus.CREATED).body(adminActivityService.createActivity(request)); + } + + @Override + @PutMapping("/{id}") + public AdminActivityDetailResponse updateActivity(@PathVariable Long id, + @Valid @RequestBody ActivityUpdateRequest request) { + return adminActivityService.updateActivity(id, request); + } + + @Override + @DeleteMapping("/{id}") + public ResponseEntity deleteActivity(@PathVariable Long id) { + adminActivityService.deleteActivity(id); + return ResponseEntity.noContent().build(); + } + + @Override + @PatchMapping("/{id}/publish") + public ResponseEntity publish(@PathVariable Long id, @Valid @RequestBody PublishRequest request) { + adminActivityService.publish(id, request); + return ResponseEntity.noContent().build(); + } + + @Override + @PatchMapping("/order") + public ResponseEntity reorder(@RequestParam Long categoryId, @RequestParam int year, + @RequestParam int month, @Valid @RequestBody OrderRequest request) { + adminActivityService.reorder(categoryId, year, month, request); + return ResponseEntity.noContent().build(); + } + + @Override + @PutMapping("/{id}/images") + public List replaceImages(@PathVariable Long id, + @Valid @RequestBody ActivityImagesReplaceRequest request) { + return adminActivityService.replaceImages(id, request); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/request/ActivityCreateRequest.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/request/ActivityCreateRequest.java new file mode 100644 index 0000000..a18dc4b --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/request/ActivityCreateRequest.java @@ -0,0 +1,39 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +/** + * 공개/숨김은 별도 PATCH .../publish로만 다룬다(트랙 페이지·커리큘럼과 동일한 규약). + * 사진은 PUT .../images 전체 교체로만 다룬다 — 항목이 여러 개고 "드래그 후 저장" 화면이라 + * study-points/tech-stacks와 같은 패턴을 쓴다. + */ +public record ActivityCreateRequest( + @NotNull Long categoryId, + + @NotNull + @Min(2000) + @Max(2100) + Integer year, + + @NotNull + @Min(1) + @Max(12) + Integer month, + + @NotBlank + @Size(max = 80) + String title, + + @NotBlank + @Size(max = 200) + String summary, + + String content, + + String externalUrl +) { +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/request/ActivityImagesReplaceRequest.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/request/ActivityImagesReplaceRequest.java new file mode 100644 index 0000000..cf1a768 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/request/ActivityImagesReplaceRequest.java @@ -0,0 +1,14 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.util.List; + +/** + * 활동 사진 전체 교체. 배열의 첫 번째가 목록 썸네일이다(INV-12). + */ +public record ActivityImagesReplaceRequest( + @NotNull + List<@NotBlank String> imageUrls +) { +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/request/ActivityUpdateRequest.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/request/ActivityUpdateRequest.java new file mode 100644 index 0000000..27916bb --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/request/ActivityUpdateRequest.java @@ -0,0 +1,36 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +/** + * categoryId는 수정 대상이 아니다 — 카테고리를 옮기는 요구가 없고, 잘못 만들었으면 + * 삭제 후 다시 만드는 편이 트리(사진 포함)를 옮기는 것보다 단순하다. + */ +public record ActivityUpdateRequest( + @NotNull + @Min(2000) + @Max(2100) + Integer year, + + @NotNull + @Min(1) + @Max(12) + Integer month, + + @NotBlank + @Size(max = 80) + String title, + + @NotBlank + @Size(max = 200) + String summary, + + String content, + + String externalUrl +) { +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/AdminActivityDetailResponse.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/AdminActivityDetailResponse.java new file mode 100644 index 0000000..0d29664 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/AdminActivityDetailResponse.java @@ -0,0 +1,34 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response; + +import com.bcsdlab.bcsdinternalapiv2.activity.model.Activity; +import java.util.List; + +public record AdminActivityDetailResponse( + Long id, + Long categoryId, + Integer year, + Integer month, + String title, + String summary, + String content, + String externalUrl, + boolean isPublished, + int displayOrder, + List imageUrls +) { + public static AdminActivityDetailResponse of(Activity activity, List imageUrls) { + return new AdminActivityDetailResponse( + activity.getId(), + activity.getCategory().getId(), + activity.getYear(), + activity.getMonth(), + activity.getTitle(), + activity.getSummary(), + activity.getContent(), + activity.getExternalUrl(), + activity.isPublished(), + activity.getDisplayOrder(), + imageUrls + ); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/AdminActivitySummaryResponse.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/AdminActivitySummaryResponse.java new file mode 100644 index 0000000..8d10237 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/controller/dto/response/AdminActivitySummaryResponse.java @@ -0,0 +1,19 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response; + +import com.bcsdlab.bcsdinternalapiv2.activity.model.Activity; + +public record AdminActivitySummaryResponse( + Long id, + Integer year, + Integer month, + String title, + String summary, + boolean isPublished, + int displayOrder +) { + public static AdminActivitySummaryResponse from(Activity activity) { + return new AdminActivitySummaryResponse( + activity.getId(), activity.getYear(), activity.getMonth(), activity.getTitle(), + activity.getSummary(), activity.isPublished(), activity.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 7d8ee04..7e8d9d8 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/exception/ActivityExceptionType.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/exception/ActivityExceptionType.java @@ -8,6 +8,7 @@ public enum ActivityExceptionType implements BcsdExceptionType { ACTIVITY_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 활동입니다."), CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 활동 카테고리입니다."), CATEGORY_SLUG_DUPLICATED(HttpStatus.CONFLICT, "이미 사용 중인 카테고리 주소입니다."), + CATEGORY_HAS_ACTIVITIES(HttpStatus.CONFLICT, "활동이 남아 있는 카테고리는 삭제할 수 없습니다."), ; private final HttpStatus status; diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/model/Activity.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/model/Activity.java new file mode 100644 index 0000000..bb76874 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/model/Activity.java @@ -0,0 +1,97 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.model; + +import com.bcsdlab.bcsdinternalapiv2.global.SoftDeletableEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLRestriction; + +/** + * 활동 타임라인의 한 항목. 최상위 엔티티라 soft delete를 쓴다(ADR-006). year/month는 + * date가 아니라 정수 두 개다 — 일(day) 개념이 없고 랜딩의 연도 그룹핑에 그대로 맞는다(ADR-007). + */ +@Getter +@Entity +@Table(name = "activity") +@SQLRestriction("deleted_at is null") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Activity extends SoftDeletableEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "category_id", nullable = false, updatable = false) + private ActivityCategory category; + + @Column(name = "year", nullable = false) + private int year; + + @Column(name = "month", nullable = false) + private int month; + + @Column(name = "title", nullable = false) + private String title; + + @Column(name = "summary", nullable = false) + private String summary; + + @Column(name = "content", columnDefinition = "text") + private String content; + + @Column(name = "external_url") + private String externalUrl; + + @Column(name = "display_order", nullable = false) + private int displayOrder; + + @Column(name = "is_published", nullable = false) + private boolean published; + + @Builder + private Activity(ActivityCategory category, int year, int month, String title, String summary, String content, + String externalUrl, int displayOrder, boolean published) { + this.category = category; + this.year = year; + this.month = month; + this.title = title; + this.summary = summary; + this.content = content; + this.externalUrl = externalUrl; + this.displayOrder = displayOrder; + this.published = published; + } + + public void updateContent(int year, int month, String title, String summary, String content, + String externalUrl) { + this.year = year; + this.month = month; + this.title = title; + this.summary = summary; + this.content = content; + this.externalUrl = externalUrl; + } + + public void updatePublished(boolean published) { + this.published = published; + } + + public void updateDisplayOrder(int displayOrder) { + this.displayOrder = displayOrder; + } + + public boolean hasDetail() { + return content != null && !content.isBlank(); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/model/ActivityImage.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/model/ActivityImage.java new file mode 100644 index 0000000..9bf706b --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/model/ActivityImage.java @@ -0,0 +1,60 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.Instant; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +/** + * 활동 사진. 하위 트리라 soft delete 없이 on delete cascade로 물리 삭제하고, 전체 + * 교체(T-15)로만 관리한다. display_order=0이 목록 썸네일이다(INV-12). + * + *

{@code activity_image} 테이블에는 {@code updated_at}이 없다 — 사진은 개별 수정 없이 + * 항상 전체 교체(삭제 후 재생성)로만 다루므로 생성 시각만 있으면 된다. 그래서 + * {@link com.bcsdlab.bcsdinternalapiv2.global.BaseTimeEntity}를 쓰지 않고 직접 매핑한다. + */ +@Getter +@Entity +@Table(name = "activity_image") +@EntityListeners(AuditingEntityListener.class) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ActivityImage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "activity_id", nullable = false, updatable = false) + private Activity activity; + + @Column(name = "image_url", nullable = false) + private String imageUrl; + + @Column(name = "display_order", nullable = false) + private int displayOrder; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Builder + private ActivityImage(Activity activity, String imageUrl, int displayOrder) { + this.activity = activity; + this.imageUrl = imageUrl; + this.displayOrder = displayOrder; + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/repository/ActivityImageRepository.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/repository/ActivityImageRepository.java new file mode 100644 index 0000000..f2aeec5 --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/repository/ActivityImageRepository.java @@ -0,0 +1,14 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.repository; + +import com.bcsdlab.bcsdinternalapiv2.activity.model.ActivityImage; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ActivityImageRepository extends JpaRepository { + + List findAllByActivity_IdOrderByDisplayOrderAsc(Long activityId); + + List findAllByActivity_IdInOrderByDisplayOrderAsc(List activityIds); + + void deleteAllByActivity_Id(Long activityId); +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/repository/ActivityRepository.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/repository/ActivityRepository.java new file mode 100644 index 0000000..7652d5e --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/repository/ActivityRepository.java @@ -0,0 +1,15 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.repository; + +import com.bcsdlab.bcsdinternalapiv2.activity.model.Activity; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +public interface ActivityRepository extends JpaRepository, JpaSpecificationExecutor { + + boolean existsByCategory_Id(Long categoryId); + + List findAllByCategory_IdAndYearAndMonthOrderByDisplayOrderAsc(Long categoryId, int year, int month); + + List findAllByCategory_IdAndPublishedTrueOrderByYearDescMonthDescDisplayOrderAsc(Long categoryId); +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/service/AdminActivityCategoryService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/service/AdminActivityCategoryService.java index 106130c..9cd168d 100644 --- a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/service/AdminActivityCategoryService.java +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/service/AdminActivityCategoryService.java @@ -7,6 +7,7 @@ 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.activity.repository.ActivityRepository; 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; @@ -24,6 +25,7 @@ public class AdminActivityCategoryService { private final ActivityCategoryRepository activityCategoryRepository; + private final ActivityRepository activityRepository; public List getCategories() { return activityCategoryRepository.findAllByOrderByDisplayOrderAsc().stream() @@ -58,6 +60,11 @@ public AdminActivityCategoryResponse updateCategory(Long id, ActivityCategoryUpd @Transactional public void deleteCategory(Long id) { + // AC-3.8: soft delete라 DB의 activity FK 제약(RESTRICT)이 이걸 막아주지 않는다 — + // 행이 물리적으로 남아 있으니 FK 위반이 나지 않는다. 애플리케이션에서 직접 검사한다. + if (activityRepository.existsByCategory_Id(id)) { + throw new ActivityException(ActivityExceptionType.CATEGORY_HAS_ACTIVITIES); + } findOrThrow(id).delete(Instant.now()); } diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/service/AdminActivityService.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/service/AdminActivityService.java new file mode 100644 index 0000000..255917c --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/service/AdminActivityService.java @@ -0,0 +1,140 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.service; + +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request.ActivityCreateRequest; +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request.ActivityImagesReplaceRequest; +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.request.ActivityUpdateRequest; +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.AdminActivityDetailResponse; +import com.bcsdlab.bcsdinternalapiv2.activity.controller.dto.response.AdminActivitySummaryResponse; +import com.bcsdlab.bcsdinternalapiv2.activity.exception.ActivityException; +import com.bcsdlab.bcsdinternalapiv2.activity.exception.ActivityExceptionType; +import com.bcsdlab.bcsdinternalapiv2.activity.model.Activity; +import com.bcsdlab.bcsdinternalapiv2.activity.model.ActivityCategory; +import com.bcsdlab.bcsdinternalapiv2.activity.model.ActivityImage; +import com.bcsdlab.bcsdinternalapiv2.activity.repository.ActivityCategoryRepository; +import com.bcsdlab.bcsdinternalapiv2.activity.repository.ActivityImageRepository; +import com.bcsdlab.bcsdinternalapiv2.activity.repository.ActivityRepository; +import com.bcsdlab.bcsdinternalapiv2.activity.util.ActivityContentSanitizer; +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.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminActivityService { + + private final ActivityRepository activityRepository; + private final ActivityImageRepository activityImageRepository; + private final ActivityCategoryRepository activityCategoryRepository; + + public Page getActivities(Long categoryId, Integer year, Boolean published, + Pageable pageable) { + List> specs = new ArrayList<>(); + if (categoryId != null) { + specs.add((root, query, cb) -> cb.equal(root.get("category").get("id"), categoryId)); + } + if (year != null) { + specs.add((root, query, cb) -> cb.equal(root.get("year"), year)); + } + if (published != null) { + specs.add((root, query, cb) -> cb.equal(root.get("published"), published)); + } + return activityRepository.findAll(Specification.allOf(specs), pageable) + .map(AdminActivitySummaryResponse::from); + } + + public AdminActivityDetailResponse getActivity(Long id) { + Activity activity = findOrThrow(id); + return AdminActivityDetailResponse.of(activity, imageUrls(id)); + } + + @Transactional + public AdminActivityDetailResponse createActivity(ActivityCreateRequest request) { + ActivityCategory category = activityCategoryRepository.findById(request.categoryId()) + .orElseThrow(() -> new ActivityException(ActivityExceptionType.CATEGORY_NOT_FOUND)); + + int displayOrder = activityRepository.findAllByCategory_IdAndYearAndMonthOrderByDisplayOrderAsc( + request.categoryId(), request.year(), request.month()).size(); + + Activity activity = activityRepository.save(Activity.builder() + .category(category) + .year(request.year()) + .month(request.month()) + .title(request.title()) + .summary(request.summary()) + .content(ActivityContentSanitizer.sanitize(request.content())) + .externalUrl(request.externalUrl()) + .displayOrder(displayOrder) + .published(true) + .build()); + return AdminActivityDetailResponse.of(activity, List.of()); + } + + @Transactional + public AdminActivityDetailResponse updateActivity(Long id, ActivityUpdateRequest request) { + Activity activity = findOrThrow(id); + activity.updateContent(request.year(), request.month(), request.title(), request.summary(), + ActivityContentSanitizer.sanitize(request.content()), request.externalUrl()); + return AdminActivityDetailResponse.of(activity, imageUrls(id)); + } + + @Transactional + public void deleteActivity(Long id) { + findOrThrow(id).delete(Instant.now()); + } + + @Transactional + public void publish(Long id, PublishRequest request) { + findOrThrow(id).updatePublished(request.isPublished()); + } + + @Transactional + public void reorder(Long categoryId, int year, int month, OrderRequest request) { + List activities = activityRepository + .findAllByCategory_IdAndYearAndMonthOrderByDisplayOrderAsc(categoryId, year, month); + Map byId = activities.stream() + .collect(Collectors.toMap(Activity::getId, activity -> activity)); + + Map newOrders = DisplayOrders.reassign(request.ids(), byId.keySet()); + newOrders.forEach((id, order) -> byId.get(id).updateDisplayOrder(order)); + } + + @Transactional + public List replaceImages(Long id, ActivityImagesReplaceRequest request) { + Activity activity = findOrThrow(id); + activityImageRepository.deleteAllByActivity_Id(id); + + List urls = request.imageUrls(); + List saved = new ArrayList<>(urls.size()); + for (int i = 0; i < urls.size(); i++) { + saved.add(activityImageRepository.save(ActivityImage.builder() + .activity(activity) + .imageUrl(urls.get(i)) + .displayOrder(i) + .build())); + } + return saved.stream().map(ActivityImage::getImageUrl).toList(); + } + + private List imageUrls(Long activityId) { + return activityImageRepository.findAllByActivity_IdOrderByDisplayOrderAsc(activityId).stream() + .map(ActivityImage::getImageUrl) + .toList(); + } + + private Activity findOrThrow(Long id) { + return activityRepository.findById(id) + .orElseThrow(() -> new ActivityException(ActivityExceptionType.ACTIVITY_NOT_FOUND)); + } +} diff --git a/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/util/ActivityContentSanitizer.java b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/util/ActivityContentSanitizer.java new file mode 100644 index 0000000..e6e50fc --- /dev/null +++ b/src/main/java/com/bcsdlab/bcsdinternalapiv2/activity/util/ActivityContentSanitizer.java @@ -0,0 +1,57 @@ +package com.bcsdlab.bcsdinternalapiv2.activity.util; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Set; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.jsoup.safety.Safelist; + +/** + * 활동 본문(리치텍스트)을 저장 시점에 정제한다(ADR-008). 렌더 시점에는 추가 정제를 하지 + * 않는다 — 저장이 유일한 관문이다. + * + *

허용 태그는 ADR-008이 정한 목록 그대로다. {@code img[src]}는 jsoup의 Safelist만으로는 + * 호스트 단위 제한이 안 되므로, safelist로 태그/속성을 거른 뒤 별도로 이미지 호스트를 + * 검사해서 허용 목록 밖이면 태그째 제거한다(INV-10). + */ +public final class ActivityContentSanitizer { + + private static final Set ALLOWED_IMAGE_HOSTS = Set.of("image.bcsdlab.com", "static.koreatech.in"); + + private static final Safelist SAFELIST = new Safelist() + .addTags("p", "br", "strong", "em", "u", "s", "ul", "ol", "li", "blockquote", + "h2", "h3", "code", "pre", "a", "img") + .addAttributes("a", "href", "target", "rel") + .addAttributes("img", "src", "alt", "width") + .addProtocols("a", "href", "http", "https") + .addProtocols("img", "src", "http", "https"); + + private ActivityContentSanitizer() { + } + + public static String sanitize(String rawHtml) { + if (rawHtml == null) { + return null; + } + + String cleaned = Jsoup.clean(rawHtml, SAFELIST); + Document document = Jsoup.parseBodyFragment(cleaned); + for (Element img : document.select("img")) { + if (!isAllowedImageHost(img.attr("src"))) { + img.remove(); + } + } + return document.body().html(); + } + + private static boolean isAllowedImageHost(String src) { + try { + String host = new URI(src).getHost(); + return host != null && ALLOWED_IMAGE_HOSTS.contains(host); + } catch (URISyntaxException e) { + return false; + } + } +} diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/activity/AdminActivityCategoryIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/activity/AdminActivityCategoryIntegrationTest.java index 72329e5..0cc7ac3 100644 --- a/src/test/java/com/bcsdlab/bcsdinternalapiv2/activity/AdminActivityCategoryIntegrationTest.java +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/activity/AdminActivityCategoryIntegrationTest.java @@ -23,6 +23,7 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.security.crypto.password.PasswordEncoder; class AdminActivityCategoryIntegrationTest extends IntegrationTestSupport { @@ -44,13 +45,20 @@ class AdminActivityCategoryIntegrationTest extends IntegrationTestSupport { @Autowired private PasswordEncoder passwordEncoder; + @Autowired + private JdbcTemplate jdbcTemplate; + private final ObjectMapper objectMapper = new ObjectMapper(); private String adminToken; @BeforeEach void setUp() throws Exception { - activityCategoryRepository.deleteAll(); + // @SQLRestriction 때문에 deleteAll()은 soft-delete된 행을 찾지 못해 물리적으로 + // 남겨 두고, 그 남은 행이 activity_category FK를 계속 참조해 다음 deleteAll()을 막는다. + jdbcTemplate.update("delete from activity_image"); + jdbcTemplate.update("delete from activity"); + jdbcTemplate.update("delete from activity_category"); refreshTokenRepository.deleteAll(); memberRepository.deleteAll(); diff --git a/src/test/java/com/bcsdlab/bcsdinternalapiv2/activity/AdminActivityIntegrationTest.java b/src/test/java/com/bcsdlab/bcsdinternalapiv2/activity/AdminActivityIntegrationTest.java new file mode 100644 index 0000000..0b70c3f --- /dev/null +++ b/src/test/java/com/bcsdlab/bcsdinternalapiv2/activity/AdminActivityIntegrationTest.java @@ -0,0 +1,212 @@ +package com.bcsdlab.bcsdinternalapiv2.activity; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +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.request.MockMvcRequestBuilders.put; +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.Activity; +import com.bcsdlab.bcsdinternalapiv2.activity.model.ActivityCategory; +import com.bcsdlab.bcsdinternalapiv2.activity.repository.ActivityCategoryRepository; +import com.bcsdlab.bcsdinternalapiv2.activity.repository.ActivityImageRepository; +import com.bcsdlab.bcsdinternalapiv2.activity.repository.ActivityRepository; +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.jdbc.core.JdbcTemplate; +import org.springframework.security.crypto.password.PasswordEncoder; + +class AdminActivityIntegrationTest extends IntegrationTestSupport { + + private static final String RAW_PASSWORD = "Temp1234"; + + @Autowired + private ActivityCategoryRepository activityCategoryRepository; + + @Autowired + private ActivityRepository activityRepository; + + @Autowired + private ActivityImageRepository activityImageRepository; + + @Autowired + private TrackMasterRepository trackMasterRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private RefreshTokenRepository refreshTokenRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private JdbcTemplate jdbcTemplate; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private ActivityCategory category; + private String adminToken; + + @BeforeEach + void setUp() throws Exception { + // @SQLRestriction 때문에 deleteAll()은 soft-delete된 행을 찾지 못해 물리적으로 + // 남겨 두고, 그 남은 행이 activity_category FK를 계속 참조해 다음 deleteAll()을 막는다. + jdbcTemplate.update("delete from activity_image"); + jdbcTemplate.update("delete from activity"); + jdbcTemplate.update("delete from activity_category"); + refreshTokenRepository.deleteAll(); + memberRepository.deleteAll(); + + category = activityCategoryRepository.save(ActivityCategory.builder() + .slug("event").name("EVENT").displayOrder(0).published(true).build()); + + 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("활동 생성 시 본문의 script 태그가 저장 전에 제거된다 (AC-3.5)") + void 생성_시_본문이_정제된다() throws Exception { + String body = mockMvc.perform(post("/v1/admin/activities") + .header("Authorization", "Bearer " + adminToken) + .contentType(MediaType.APPLICATION_JSON) + .content(("{\"categoryId\":%d,\"year\":2019,\"month\":5,\"title\":\"컨퍼런스\"," + + "\"summary\":\"요약\",\"content\":\"

안내

\"}") + .formatted(category.getId()))) + .andExpect(status().isCreated()) + .andReturn().getResponse().getContentAsString(); + + String content = objectMapper.readTree(body).get("content").asText(); + assertThat(content).doesNotContainIgnoringCase("안내

"); + + assertThat(result).doesNotContainIgnoringCase("안내

"); + + assertThat(result).doesNotContain("onclick"); + } + + @Test + @DisplayName("허용 목록 밖 태그(iframe)는 제거된다") + void 허용_목록_밖_태그는_제거된다() { + String result = ActivityContentSanitizer.sanitize("

안내

"); + + assertThat(result).doesNotContainIgnoringCase(""); + + assertThat(result).doesNotContain("evil.com"); + } + + @Test + @DisplayName("허용된 호스트의 이미지는 보존된다") + void 허용된_호스트의_이미지는_보존된다() { + String result = ActivityContentSanitizer.sanitize("\"a\""); + + assertThat(result).contains("image.bcsdlab.com/a.png"); + } + + @Test + @DisplayName("허용 태그·속성은 그대로 보존된다 (과잉 정제 방지)") + void 허용_태그와_속성은_보존된다() { + String result = ActivityContentSanitizer.sanitize( + "

제 1회 BCSD Lab Conference를 개최했습니다.

" + + "
  • 세션 A
  • 세션 B
" + + "링크"); + + assertThat(result) + .contains("BCSD Lab Conference") + .contains("
  • 세션 A
  • ") + .contains("href=\"https://bcsdlab.com\""); + } + + @Test + @DisplayName("null 입력은 null을 반환한다") + void null_입력은_null을_반환한다() { + assertThat(ActivityContentSanitizer.sanitize(null)).isNull(); + } +}