Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.bcsdlab.bcsdinternalapiv2.curriculum.controller;

import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.request.CurriculumUpdateRequest;
import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.response.AdminCurriculumSummaryResponse;
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 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 AdminCurriculumApi {

@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 = "커리큘럼 세트 이름 수정")
AdminCurriculumSummaryResponse updateCurriculum(@PathVariable Long id,
@RequestBody @Valid CurriculumUpdateRequest 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 — 하위 주차·토픽·세부항목은 cascade로 물리 삭제된다.")
ResponseEntity<Void> deleteCurriculum(@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 = "공개/숨김", description = "공개로 지정하면 같은 트랙의 기존 공개 세트가 자동으로 비공개된다(AC-2.1).")
ResponseEntity<Void> publish(@PathVariable Long id, @RequestBody @Valid PublishRequest request);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package com.bcsdlab.bcsdinternalapiv2.curriculum.controller;

import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.request.CurriculumUpdateRequest;
import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.response.AdminCurriculumSummaryResponse;
import com.bcsdlab.bcsdinternalapiv2.curriculum.service.AdminCurriculumService;
import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.PublishRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
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/curriculums")
@RequiredArgsConstructor
public class AdminCurriculumController implements AdminCurriculumApi {

private final AdminCurriculumService adminCurriculumService;

@Override
@PutMapping("/{id}")
public AdminCurriculumSummaryResponse updateCurriculum(@PathVariable Long id,
@Valid @RequestBody CurriculumUpdateRequest request) {
return adminCurriculumService.updateCurriculum(id, request);
}

@Override
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteCurriculum(@PathVariable Long id) {
adminCurriculumService.deleteCurriculum(id);
return ResponseEntity.noContent().build();
}

@Override
@PatchMapping("/{id}/publish")
public ResponseEntity<Void> publish(@PathVariable Long id, @Valid @RequestBody PublishRequest request) {
adminCurriculumService.publish(id, request);
return ResponseEntity.noContent().build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.bcsdlab.bcsdinternalapiv2.curriculum.controller;

import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.request.CurriculumCreateRequest;
import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.response.AdminCurriculumSummaryResponse;
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 AdminCurriculumSetApi {

@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 = "커리큘럼 세트 목록")
List<AdminCurriculumSummaryResponse> getCurriculums(@PathVariable Long trackPageId);

@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 = "404", content = @Content(schema = @Schema(hidden = true))),
})
@Operation(summary = "커리큘럼 세트 생성", description = "생성 직후에는 비공개다.")
ResponseEntity<AdminCurriculumSummaryResponse> createCurriculum(@PathVariable Long trackPageId,
@RequestBody @Valid CurriculumCreateRequest request);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.bcsdlab.bcsdinternalapiv2.curriculum.controller;

import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.request.CurriculumCreateRequest;
import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.response.AdminCurriculumSummaryResponse;
import com.bcsdlab.bcsdinternalapiv2.curriculum.service.AdminCurriculumService;
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.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
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/track-pages/{trackPageId}/curriculums")
@RequiredArgsConstructor
public class AdminCurriculumSetController implements AdminCurriculumSetApi {

private final AdminCurriculumService adminCurriculumService;

@Override
@GetMapping
public List<AdminCurriculumSummaryResponse> getCurriculums(@PathVariable Long trackPageId) {
return adminCurriculumService.getCurriculums(trackPageId);
}

@Override
@PostMapping
public ResponseEntity<AdminCurriculumSummaryResponse> createCurriculum(
@PathVariable Long trackPageId, @Valid @RequestBody CurriculumCreateRequest request) {
return ResponseEntity.status(HttpStatus.CREATED)
.body(adminCurriculumService.createCurriculum(trackPageId, request));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.request;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

/**
* sourceCurriculumId는 T-13(세트 복제)에서 추가된다. 지금은 순수 생성만 지원한다.
*/
public record CurriculumCreateRequest(
@NotBlank
@Size(max = 50)
String name
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.request;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public record CurriculumUpdateRequest(
@NotBlank
@Size(max = 50)
String name
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.response;

import com.bcsdlab.bcsdinternalapiv2.curriculum.model.Curriculum;

/**
* 세트 목록용 요약. 주차·토픽 수는 아직 없다 — 그 개념이 생기는 T-11에서 이 레코드에
* 필드를 추가한다.
*/
public record AdminCurriculumSummaryResponse(
Long id,
String name,
boolean isPublished,
int displayOrder
) {
public static AdminCurriculumSummaryResponse from(Curriculum curriculum) {
return new AdminCurriculumSummaryResponse(
curriculum.getId(), curriculum.getName(), curriculum.isPublished(), curriculum.getDisplayOrder());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package com.bcsdlab.bcsdinternalapiv2.curriculum.model;

import com.bcsdlab.bcsdinternalapiv2.global.SoftDeletableEntity;
import com.bcsdlab.bcsdinternalapiv2.track.model.TrackPage;
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;

/**
* 트랙 페이지의 커리큘럼 세트("비기너", "심화"). 트랙당 공개 세트는 하나뿐이다
* (INV-2, {@code uq_curriculum_published} partial unique index).
*/
@Getter
@Entity
@Table(name = "curriculum")
@SQLRestriction("deleted_at is null")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Curriculum extends SoftDeletableEntity {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "track_page_id", nullable = false, updatable = false)
private TrackPage trackPage;

@Column(name = "name", nullable = false)
private String name;

@Column(name = "is_published", nullable = false)
private boolean published;

@Column(name = "display_order", nullable = false)
private int displayOrder;

@Builder
private Curriculum(TrackPage trackPage, String name, boolean published, int displayOrder) {
this.trackPage = trackPage;
this.name = name;
this.published = published;
this.displayOrder = displayOrder;
}

public void rename(String name) {
this.name = name;
}

public void updatePublished(boolean published) {
this.published = published;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.bcsdlab.bcsdinternalapiv2.curriculum.repository;

import com.bcsdlab.bcsdinternalapiv2.curriculum.model.Curriculum;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;

public interface CurriculumRepository extends JpaRepository<Curriculum, Long> {

List<Curriculum> findAllByTrackPage_IdOrderByDisplayOrderAsc(Long trackPageId);

Optional<Curriculum> findByTrackPage_IdAndPublishedTrue(Long trackPageId);

/**
* AC-2.1: 새 세트를 공개하기 전에, 같은 트랙의 다른 공개 세트를 먼저 비공개로 내린다.
* 벌크 UPDATE로 즉시 실행되므로, 뒤이어 대상 엔티티를 공개로 바꿔도
* uq_curriculum_published(partial unique index)에 걸리지 않는다.
*/
@Modifying
@Transactional
@Query("update Curriculum c set c.published = false "
+ "where c.trackPage.id = :trackPageId and c.id <> :excludeId and c.published = true")
int unpublishOthers(@Param("trackPageId") Long trackPageId, @Param("excludeId") Long excludeId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package com.bcsdlab.bcsdinternalapiv2.curriculum.service;

import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.request.CurriculumCreateRequest;
import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.request.CurriculumUpdateRequest;
import com.bcsdlab.bcsdinternalapiv2.curriculum.controller.dto.response.AdminCurriculumSummaryResponse;
import com.bcsdlab.bcsdinternalapiv2.curriculum.exception.CurriculumException;
import com.bcsdlab.bcsdinternalapiv2.curriculum.exception.CurriculumExceptionType;
import com.bcsdlab.bcsdinternalapiv2.curriculum.model.Curriculum;
import com.bcsdlab.bcsdinternalapiv2.curriculum.repository.CurriculumRepository;
import com.bcsdlab.bcsdinternalapiv2.global.controller.dto.request.PublishRequest;
import com.bcsdlab.bcsdinternalapiv2.track.exception.TrackException;
import com.bcsdlab.bcsdinternalapiv2.track.exception.TrackExceptionType;
import com.bcsdlab.bcsdinternalapiv2.track.model.TrackPage;
import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackPageRepository;
import java.time.Instant;
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 AdminCurriculumService {

private final CurriculumRepository curriculumRepository;
private final TrackPageRepository trackPageRepository;

public List<AdminCurriculumSummaryResponse> getCurriculums(Long trackPageId) {
return curriculumRepository.findAllByTrackPage_IdOrderByDisplayOrderAsc(trackPageId).stream()
.map(AdminCurriculumSummaryResponse::from)
.toList();
}

@Transactional
public AdminCurriculumSummaryResponse createCurriculum(Long trackPageId, CurriculumCreateRequest request) {
TrackPage trackPage = trackPageRepository.findById(trackPageId)
.orElseThrow(() -> new TrackException(TrackExceptionType.TRACK_PAGE_NOT_FOUND));

int displayOrder = curriculumRepository.findAllByTrackPage_IdOrderByDisplayOrderAsc(trackPageId).size();
Curriculum curriculum = curriculumRepository.save(Curriculum.builder()
.trackPage(trackPage)
.name(request.name())
.published(false)
.displayOrder(displayOrder)
.build());
return AdminCurriculumSummaryResponse.from(curriculum);
}

@Transactional
public AdminCurriculumSummaryResponse updateCurriculum(Long id, CurriculumUpdateRequest request) {
Curriculum curriculum = findOrThrow(id);
curriculum.rename(request.name());
return AdminCurriculumSummaryResponse.from(curriculum);
}

@Transactional
public void deleteCurriculum(Long id) {
findOrThrow(id).delete(Instant.now());
}

@Transactional
public void publish(Long id, PublishRequest request) {
Curriculum curriculum = findOrThrow(id);
if (request.isPublished()) {
// AC-2.1: 벌크 UPDATE로 다른 공개 세트를 먼저 내려야, 뒤이은 dirty-check 반영 시
// uq_curriculum_published(partial unique index)에 걸리지 않는다.
curriculumRepository.unpublishOthers(curriculum.getTrackPage().getId(), id);
}
curriculum.updatePublished(request.isPublished());
}

private Curriculum findOrThrow(Long id) {
return curriculumRepository.findById(id)
.orElseThrow(() -> new CurriculumException(CurriculumExceptionType.CURRICULUM_NOT_FOUND));
}
}
Loading