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
Expand Up @@ -40,6 +40,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
.requestMatchers("/v1/auth/login", "/v1/auth/reissue", "/v1/auth/logout")
.permitAll()
.requestMatchers("/v1/auth/password/**").permitAll()
.requestMatchers("/v1/tracks/**").permitAll()
.requestMatchers("/v1/members/me/initial-setup")
.hasAnyAuthority("SCOPE_PRE_ACTIVATION", "SCOPE_FULL")
.requestMatchers("/v1/admin/**").access(AuthorizationManagers.allOf(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.bcsdlab.bcsdinternalapiv2.track.controller;

import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response.TrackDetailResponse;
import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response.TrackSummaryResponse;
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.tags.Tag;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

@Tag(name = "트랙 API (홈페이지 공개)")
public interface TrackApi {

@ApiResponses(value = {
@ApiResponse(responseCode = "200"),
})
@Operation(summary = "공개 트랙 목록", description = "홈페이지 노출 순서(display_order)대로, 공개된 트랙만 반환합니다.")
@GetMapping
List<TrackSummaryResponse> getTracks();

@ApiResponses(value = {
@ApiResponse(responseCode = "200"),
@ApiResponse(responseCode = "404", content = @Content(schema = @Schema(hidden = true))),
})
@Operation(summary = "트랙 페이지 상세", description = "숨김 처리되었거나 존재하지 않는 트랙은 구분 없이 404를 반환합니다.")
@GetMapping("/{slug}")
TrackDetailResponse getTrack(@PathVariable String slug);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.bcsdlab.bcsdinternalapiv2.track.controller;

import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response.TrackDetailResponse;
import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response.TrackSummaryResponse;
import com.bcsdlab.bcsdinternalapiv2.track.service.TrackService;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/v1/tracks")
@RequiredArgsConstructor
public class TrackController implements TrackApi {

private final TrackService trackService;

@Override
@GetMapping
public List<TrackSummaryResponse> getTracks() {
return trackService.getTracks();
}

@Override
@GetMapping("/{slug}")
public TrackDetailResponse getTrack(@PathVariable String slug) {
return trackService.getTrack(slug);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response;

import com.bcsdlab.bcsdinternalapiv2.track.model.TrackPage;

/**
* T-06은 헤더 필드만 채운다. studyPoints(T-08)·techStacks(T-09)·curriculum(T-12)·
* members(T-18)는 각 도메인 티켓이 이 레코드에 필드를 추가하는 방식으로 확장한다 —
* 지금 존재하지 않는 데이터를 위해 빈 자리표시자 구조를 미리 만들지 않는다.
*/
public record TrackDetailResponse(
String slug,
String name,
String tagline,
String heroImageUrl,
String ogImageUrl,
String seoDescription
) {
public static TrackDetailResponse from(TrackPage trackPage) {
return new TrackDetailResponse(
trackPage.getSlug(),
trackPage.getDisplayName(),
trackPage.getTagline(),
trackPage.getHeroImageUrl(),
trackPage.getOgImageUrl(),
trackPage.getSeoDescription()
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response;

import com.bcsdlab.bcsdinternalapiv2.track.model.TrackPage;

public record TrackSummaryResponse(
String slug,
String name
) {
public static TrackSummaryResponse from(TrackPage trackPage) {
return new TrackSummaryResponse(trackPage.getSlug(), trackPage.getDisplayName());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package com.bcsdlab.bcsdinternalapiv2.track.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;

/**
* 홈페이지에 노출되는 트랙 프로필. 트랙 마스터({@link TrackMaster}, "부원이 어디에 속하는가")와
* 의도적으로 분리되어 있다 — 이 엔티티는 "홈페이지에 어떻게 보이는가"만 다룬다(ADR-002/003).
*
* <p>{@code @SQLRestriction}은 {@link SoftDeletableEntity}(매핑된 슈퍼클래스)가 아니라
* 이 클래스에 직접 붙어 있다 — Hibernate가 상속시키지 않기 때문이다.
*/
@Getter
@Entity
@Table(name = "track_page")
@SQLRestriction("deleted_at is null")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class TrackPage extends SoftDeletableEntity {

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

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "track_id", nullable = false, updatable = false)
private TrackMaster track;

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

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

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

@Column(name = "hero_image_url")
private String heroImageUrl;

@Column(name = "og_image_url")
private String ogImageUrl;

@Column(name = "seo_description")
private String seoDescription;

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

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

@Builder
private TrackPage(TrackMaster track, String slug, String displayName, String tagline, String heroImageUrl,
String ogImageUrl, String seoDescription, int displayOrder, boolean published) {
this.track = track;
this.slug = slug;
this.displayName = displayName;
this.tagline = tagline;
this.heroImageUrl = heroImageUrl;
this.ogImageUrl = ogImageUrl;
this.seoDescription = seoDescription;
this.displayOrder = displayOrder;
this.published = published;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.bcsdlab.bcsdinternalapiv2.track.repository;

import com.bcsdlab.bcsdinternalapiv2.track.model.TrackPage;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;

public interface TrackPageRepository extends JpaRepository<TrackPage, Long> {

List<TrackPage> findAllByPublishedTrueOrderByDisplayOrderAsc();

Optional<TrackPage> findBySlugAndPublishedTrue(String slug);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.bcsdlab.bcsdinternalapiv2.track.service;

import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response.TrackDetailResponse;
import com.bcsdlab.bcsdinternalapiv2.track.controller.dto.response.TrackSummaryResponse;
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.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class TrackService {

private final TrackPageRepository trackPageRepository;

public List<TrackSummaryResponse> getTracks() {
return trackPageRepository.findAllByPublishedTrueOrderByDisplayOrderAsc().stream()
.map(TrackSummaryResponse::from)
.toList();
}

public TrackDetailResponse getTrack(String slug) {
TrackPage trackPage = trackPageRepository.findBySlugAndPublishedTrue(slug)
.orElseThrow(() -> new TrackException(TrackExceptionType.TRACK_NOT_FOUND));
return TrackDetailResponse.from(trackPage);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package com.bcsdlab.bcsdinternalapiv2.track;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
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.track.model.TrackMaster;
import com.bcsdlab.bcsdinternalapiv2.track.model.TrackPage;
import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackMasterRepository;
import com.bcsdlab.bcsdinternalapiv2.track.repository.TrackPageRepository;
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;

class TrackIntegrationTest extends IntegrationTestSupport {

@Autowired
private TrackPageRepository trackPageRepository;

@Autowired
private TrackMasterRepository trackMasterRepository;

private TrackMaster frontend;
private TrackMaster backend;
private TrackMaster pm;

@BeforeEach
void setUp() {
trackPageRepository.deleteAll();
frontend = trackMasterRepository.findByCode("FRONTEND").orElseThrow();
backend = trackMasterRepository.findByCode("BACKEND").orElseThrow();
pm = trackMasterRepository.findByCode("PM").orElseThrow();
}

@Test
@DisplayName("AC-1.3 공개 목록은 공개된 트랙만 display_order 순으로 반환한다")
void 공개_목록은_display_order_순이고_숨김_트랙은_제외한다() throws Exception {
// uq_track_page_track은 track_id 단위 partial unique index라, 활성 상태(미삭제)인
// track_page는 트랙마다 하나뿐이어야 한다 — 숨김 케이스는 별도 트랙 마스터(pm)를 쓴다.
trackPageRepository.save(trackPage(backend, "backend", 1, true));
trackPageRepository.save(trackPage(frontend, "frontend", 0, true));
trackPageRepository.save(trackPage(pm, "hidden", 2, false));

mockMvc.perform(get("/v1/tracks"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.length()").value(2))
.andExpect(jsonPath("$[0].slug").value("frontend"))
.andExpect(jsonPath("$[1].slug").value("backend"));
}

@Test
@DisplayName("공개 트랙 상세는 인증 없이 200과 헤더 필드를 반환한다")
void 공개_트랙_상세는_인증_없이_조회된다() throws Exception {
trackPageRepository.save(TrackPage.builder()
.track(frontend)
.slug("frontend")
.displayName("Frontend")
.tagline("사용자 인터페이스를 구축합니다.")
.heroImageUrl("https://image.bcsdlab.com/hero.png")
.displayOrder(0)
.published(true)
.build());

mockMvc.perform(get("/v1/tracks/frontend"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.slug").value("frontend"))
.andExpect(jsonPath("$.name").value("Frontend"))
.andExpect(jsonPath("$.tagline").value("사용자 인터페이스를 구축합니다."))
.andExpect(jsonPath("$.heroImageUrl").value("https://image.bcsdlab.com/hero.png"));
}

@Test
@DisplayName("INV-11 숨김 트랙은 slug를 알아도 404다 (존재하지 않는 slug와 구분되지 않는다)")
void 숨김_트랙은_404다() throws Exception {
trackPageRepository.save(trackPage(frontend, "hidden-track", 0, false));

mockMvc.perform(get("/v1/tracks/hidden-track")).andExpect(status().isNotFound());
mockMvc.perform(get("/v1/tracks/no-such-slug")).andExpect(status().isNotFound());
}

private TrackPage trackPage(TrackMaster track, String slug, int order, boolean published) {
return TrackPage.builder()
.track(track)
.slug(slug)
.displayName(slug)
.tagline("tagline")
.displayOrder(order)
.published(published)
.build();
}
}