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
177 changes: 177 additions & 0 deletions src/main/resources/db/migration/V9__create_homepage_content.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
-- 홈페이지 CMS 스키마 (05-api-spec.md §1.2). track_page는 트랙 마스터(V7)를 참조한다.
-- 최상위 엔티티(track_page/curriculum/activity_category/activity)만 soft delete를 쓰고,
-- 하위 트리는 ON DELETE CASCADE로 물리 삭제한다.

-- ===== 홈페이지 노출 프로필 (track 1 : track_page 0..1) =====
CREATE TABLE track_page (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
track_id BIGINT NOT NULL REFERENCES track (id),
slug VARCHAR(50) NOT NULL,
display_name VARCHAR(50) NOT NULL,
tagline VARCHAR(120) NOT NULL,
hero_image_url VARCHAR(500),
og_image_url VARCHAR(500),
seo_description VARCHAR(200),
display_order INT NOT NULL,
is_published BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX uq_track_page_slug ON track_page (slug) WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX uq_track_page_track ON track_page (track_id) WHERE deleted_at IS NULL;

CREATE TABLE track_study_point (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
track_page_id BIGINT NOT NULL REFERENCES track_page (id) ON DELETE CASCADE,
title VARCHAR(60) NOT NULL,
description VARCHAR(200) NOT NULL,
icon_image_url VARCHAR(500),
display_order INT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_study_point_page ON track_study_point (track_page_id, display_order);

CREATE TABLE tech_stack (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name VARCHAR(50) NOT NULL,
icon_url VARCHAR(500) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),

CONSTRAINT uk_tech_stack_name UNIQUE (name)
);

CREATE TABLE track_page_tech_stack (
track_page_id BIGINT NOT NULL REFERENCES track_page (id) ON DELETE CASCADE,
tech_stack_id BIGINT NOT NULL REFERENCES tech_stack (id) ON DELETE CASCADE,
display_order INT NOT NULL,

PRIMARY KEY (track_page_id, tech_stack_id)
);

-- 명부(member)가 이미 구현되어 있으므로 FK를 즉시 적용한다 (ADR-012).
CREATE TABLE track_page_member (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
track_page_id BIGINT NOT NULL REFERENCES track_page (id) ON DELETE CASCADE,
member_id BIGINT NOT NULL REFERENCES member (id),
display_order INT NOT NULL,
is_visible BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),

CONSTRAINT uk_track_page_member UNIQUE (track_page_id, member_id)
);

-- ===== 커리큘럼 =====
CREATE TABLE curriculum (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
track_page_id BIGINT NOT NULL REFERENCES track_page (id) ON DELETE CASCADE,
name VARCHAR(50) NOT NULL,
is_published BOOLEAN NOT NULL DEFAULT FALSE,
display_order INT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
-- INV-2: 트랙당 공개 세트는 1개만 (ADR-004)
CREATE UNIQUE INDEX uq_curriculum_published
ON curriculum (track_page_id) WHERE is_published AND deleted_at IS NULL;

CREATE TABLE curriculum_week (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
curriculum_id BIGINT NOT NULL REFERENCES curriculum (id) ON DELETE CASCADE,
week_from INT NOT NULL,
week_to INT,
display_order INT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),

CONSTRAINT ck_week_range CHECK (week_to IS NULL OR week_to >= week_from),
CONSTRAINT ck_week_from CHECK (week_from BETWEEN 1 AND 99)
);
CREATE INDEX idx_week_curriculum ON curriculum_week (curriculum_id, display_order);

CREATE TABLE curriculum_topic (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
week_id BIGINT NOT NULL REFERENCES curriculum_week (id) ON DELETE CASCADE,
title VARCHAR(200) NOT NULL,
display_order INT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_topic_week ON curriculum_topic (week_id, display_order);

CREATE TABLE curriculum_topic_detail (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
topic_id BIGINT NOT NULL REFERENCES curriculum_topic (id) ON DELETE CASCADE,
content VARCHAR(300) NOT NULL,
display_order INT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_detail_topic ON curriculum_topic_detail (topic_id, display_order);

-- ===== 활동 =====
CREATE TABLE activity_category (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
slug VARCHAR(30) NOT NULL,
name VARCHAR(30) NOT NULL,
headline VARCHAR(200),
hero_image_url VARCHAR(500),
display_order INT NOT NULL,
is_published BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX uq_activity_category_slug ON activity_category (slug) WHERE deleted_at IS NULL;

CREATE TABLE activity (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
category_id BIGINT NOT NULL REFERENCES activity_category (id),
year INT NOT NULL,
month INT NOT NULL,
title VARCHAR(80) NOT NULL,
summary VARCHAR(200) NOT NULL,
content TEXT,
external_url VARCHAR(500),
display_order INT NOT NULL,
is_published BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ,

CONSTRAINT ck_activity_month CHECK (month BETWEEN 1 AND 12),
CONSTRAINT ck_activity_year CHECK (year BETWEEN 2000 AND 2100)
);
CREATE INDEX idx_activity_timeline
ON activity (category_id, year DESC, month DESC, display_order)
WHERE deleted_at IS NULL;

CREATE TABLE activity_image (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
activity_id BIGINT NOT NULL REFERENCES activity (id) ON DELETE CASCADE,
image_url VARCHAR(500) NOT NULL,
display_order INT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_activity_image ON activity_image (activity_id, display_order);

-- ===== 미디어 라이브러리 (콘텐츠 테이블과 FK로 묶지 않는다 · ADR-009) =====
CREATE TABLE image_asset (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
s3_key VARCHAR(300) NOT NULL,
url VARCHAR(500) NOT NULL,
original_name VARCHAR(255) NOT NULL,
content_type VARCHAR(100) NOT NULL,
byte_size BIGINT NOT NULL,
purpose VARCHAR(30) NOT NULL,
is_confirmed BOOLEAN NOT NULL DEFAULT FALSE,
uploaded_by BIGINT REFERENCES member (id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),

CONSTRAINT uk_image_asset_s3_key UNIQUE (s3_key)
);
CREATE INDEX idx_image_asset_library ON image_asset (purpose, created_at DESC) WHERE is_confirmed;
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
package com.bcsdlab.bcsdinternalapiv2.track;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;

import com.bcsdlab.bcsdinternalapiv2.IntegrationTestSupport;
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.dao.DataIntegrityViolationException;
import org.springframework.jdbc.core.JdbcTemplate;

/**
* T-05(V9__create_homepage_content.sql)의 제약 조건이 설계대로 동작하는지 검증한다.
* 아직 JPA 엔티티가 없으므로(T-06 이후 도메인 티켓의 몫) raw SQL로 직접 검증한다 —
* partial unique index, CHECK 제약, ON DELETE CASCADE처럼 오타 하나로도 조용히 깨지는
* DDL을 엔티티가 생기기 전에 먼저 굳혀 둔다.
*/
class HomepageContentSchemaIntegrationTest extends IntegrationTestSupport {

@Autowired
private JdbcTemplate jdbcTemplate;

private Long frontendTrackId;
private Long backendTrackId;

@BeforeEach
void setUp() {
jdbcTemplate.update("delete from activity");
jdbcTemplate.update("delete from activity_category");
jdbcTemplate.update("delete from curriculum");
jdbcTemplate.update("delete from track_page");
jdbcTemplate.update("delete from image_asset");

frontendTrackId = jdbcTemplate.queryForObject(
"select id from track where code = 'FRONTEND'", Long.class);
backendTrackId = jdbcTemplate.queryForObject(
"select id from track where code = 'BACKEND'", Long.class);
}

@Test
@DisplayName("INV-1 slug는 삭제되지 않은 track_page끼리만 유일하다")
void track_page_slug는_삭제되지_않은_행끼리만_유일하다() {
insertTrackPage(frontendTrackId, "frontend");

assertThatThrownBy(() -> insertTrackPage(backendTrackId, "frontend"))
.isInstanceOf(DataIntegrityViolationException.class);

jdbcTemplate.update("update track_page set deleted_at = now() where slug = 'frontend'");

// 예외를 던지지 않으면 성공
insertTrackPage(backendTrackId, "frontend");
}

@Test
@DisplayName("INV-2 한 트랙에 공개 커리큘럼 세트는 하나만 허용한다")
void 공개_커리큘럼은_트랙당_하나만_허용한다() {
Long trackPageId = insertTrackPage(frontendTrackId, "frontend");
insertCurriculum(trackPageId, "비기너", true);

assertThatThrownBy(() -> insertCurriculum(trackPageId, "심화", true))
.isInstanceOf(DataIntegrityViolationException.class);

// 공개가 아니면 여러 개 허용
insertCurriculum(trackPageId, "초안", false);
}

@Test
@DisplayName("INV-5 week_to가 week_from보다 작으면 거부된다")
void 주차_범위가_거꾸로면_거부된다() {
Long trackPageId = insertTrackPage(frontendTrackId, "frontend");
Long curriculumId = insertCurriculum(trackPageId, "비기너", true);

assertThatThrownBy(() -> jdbcTemplate.update(
"insert into curriculum_week (curriculum_id, week_from, week_to, display_order) "
+ "values (?, 5, 3, 0)", curriculumId))
.isInstanceOf(DataIntegrityViolationException.class);

jdbcTemplate.update(
"insert into curriculum_week (curriculum_id, week_from, week_to, display_order) "
+ "values (?, 13, 16, 0)", curriculumId);
jdbcTemplate.update(
"insert into curriculum_week (curriculum_id, week_from, week_to, display_order) "
+ "values (?, 4, null, 1)", curriculumId);
}

@Test
@DisplayName("INV-6 활동의 month/year 범위를 벗어나면 거부된다")
void 활동_연월_범위를_벗어나면_거부된다() {
Long categoryId = insertActivityCategory("event", "EVENT");

assertThatThrownBy(() -> insertActivity(categoryId, 2019, 13))
.isInstanceOf(DataIntegrityViolationException.class);
assertThatThrownBy(() -> insertActivity(categoryId, 1999, 5))
.isInstanceOf(DataIntegrityViolationException.class);

insertActivity(categoryId, 2019, 5);
}

@Test
@DisplayName("INV-7 커리큘럼을 삭제하면 주차·토픽·세부항목이 cascade로 함께 삭제된다")
void 커리큘럼_삭제는_하위_트리에_cascade된다() {
Long trackPageId = insertTrackPage(frontendTrackId, "frontend");
Long curriculumId = insertCurriculum(trackPageId, "비기너", true);
Long weekId = jdbcTemplate.queryForObject(
"insert into curriculum_week (curriculum_id, week_from, display_order) "
+ "values (?, 1, 0) returning id",
Long.class, curriculumId);
Long topicId = jdbcTemplate.queryForObject(
"insert into curriculum_topic (week_id, title, display_order) values (?, '웹 동작 방식', 0) "
+ "returning id",
Long.class, weekId);
jdbcTemplate.update(
"insert into curriculum_topic_detail (topic_id, content, display_order) values (?, 'HTTP', 0)",
topicId);

jdbcTemplate.update("delete from curriculum where id = ?", curriculumId);

assertThat(jdbcTemplate.queryForObject(
"select count(*) from curriculum_week where id = ?", Integer.class, weekId)).isZero();
assertThat(jdbcTemplate.queryForObject(
"select count(*) from curriculum_topic where id = ?", Integer.class, topicId)).isZero();
assertThat(jdbcTemplate.queryForObject(
"select count(*) from curriculum_topic_detail where topic_id = ?", Integer.class, topicId))
.isZero();
}

@Test
@DisplayName("INV-7 활동을 삭제하면 사진도 cascade로 함께 삭제된다")
void 활동_삭제는_사진에_cascade된다() {
Long categoryId = insertActivityCategory("event", "EVENT");
Long activityId = insertActivity(categoryId, 2019, 5);
jdbcTemplate.update(
"insert into activity_image (activity_id, image_url, display_order) values (?, 'https://x/1.png', 0)",
activityId);

jdbcTemplate.update("delete from activity where id = ?", activityId);

assertThat(jdbcTemplate.queryForObject(
"select count(*) from activity_image where activity_id = ?", Integer.class, activityId))
.isZero();
}

@Test
@DisplayName("카테고리에 활동이 남아 있으면 FK 제약으로 삭제가 거부된다 (AC-3.8의 DB 쪽 근거)")
void 활동이_남은_카테고리는_삭제할_수_없다() {
Long categoryId = insertActivityCategory("event", "EVENT");
insertActivity(categoryId, 2019, 5);

assertThatThrownBy(() -> jdbcTemplate.update("delete from activity_category where id = ?", categoryId))
.isInstanceOf(DataIntegrityViolationException.class);
}

private Long insertTrackPage(Long trackId, String slug) {
return jdbcTemplate.queryForObject(
"insert into track_page (track_id, slug, display_name, tagline, display_order) "
+ "values (?, ?, ?, 'tagline', 0) returning id",
Long.class, trackId, slug, slug);
}

private Long insertCurriculum(Long trackPageId, String name, boolean published) {
return jdbcTemplate.queryForObject(
"insert into curriculum (track_page_id, name, is_published, display_order) "
+ "values (?, ?, ?, 0) returning id",
Long.class, trackPageId, name, published);
}

private Long insertActivityCategory(String slug, String name) {
return jdbcTemplate.queryForObject(
"insert into activity_category (slug, name, display_order) values (?, ?, 0) returning id",
Long.class, slug, name);
}

private Long insertActivity(Long categoryId, int year, int month) {
return jdbcTemplate.queryForObject(
"insert into activity (category_id, year, month, title, summary, display_order) "
+ "values (?, ?, ?, 't', 's', 0) returning id",
Long.class, categoryId, year, month);
}
}