diff --git a/build.gradle b/build.gradle index 31696399..7adfbd8f 100644 --- a/build.gradle +++ b/build.gradle @@ -46,6 +46,10 @@ dependencies { // Redis implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + // Flyway + implementation 'org.flywaydb:flyway-core' + implementation 'org.flywaydb:flyway-database-postgresql' } jar { enabled = false } diff --git a/src/main/resources/application-prod.yaml b/src/main/resources/application-prod.yaml index 9505d639..1431fd00 100644 --- a/src/main/resources/application-prod.yaml +++ b/src/main/resources/application-prod.yaml @@ -6,11 +6,15 @@ spring: driver-class-name: org.postgresql.Driver jpa: hibernate: - ddl-auto: update + ddl-auto: validate show-sql: false properties: hibernate: dialect: org.hibernate.dialect.PostgreSQLDialect + flyway: + enabled: true + baseline-on-migrate: true + baseline-version: 1 data: redis: host: ${REDIS_HOST} @@ -32,4 +36,4 @@ tracking: consumer-group: ${TRACKING_CONSUMER_GROUP} test: - secret-key: ${TEST_SECRET_KEY} \ No newline at end of file + secret-key: ${TEST_SECRET_KEY} diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 3f3b1846..17892458 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -12,12 +12,20 @@ spring: driver-class-name: org.postgresql.Driver jpa: hibernate: - ddl-auto: update + ddl-auto: validate show-sql: false properties: hibernate: dialect: org.hibernate.dialect.PostgreSQLDialect + flyway: + enabled: true + baseline-on-migrate: true + baseline-version: 1 + data: + redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT} jwt: secret: ${JWT_SECRET} @@ -30,11 +38,6 @@ kakao: client-secret: ${KAKAO_CLIENT_SECRET} admin-key: ${KAKAO_ADMIN_KEY} - data: - redis: - host: ${REDIS_HOST} - port: ${REDIS_PORT} - tracking: stream-key: ${TRACKING_STREAM_KEY} consumer-group: ${TRACKING_CONSUMER_GROUP} diff --git a/src/main/resources/db/migration/README.md b/src/main/resources/db/migration/README.md new file mode 100644 index 00000000..1c170b4e --- /dev/null +++ b/src/main/resources/db/migration/README.md @@ -0,0 +1,16 @@ +# Flyway Migration Guide + +이 디렉터리에는 운영 DB 스키마 변경 SQL을 추가합니다. + +- 한 번 배포된 migration 파일은 수정하지 않습니다. +- 변경이 필요하면 새 버전의 migration 파일을 추가합니다. +- 파일명은 `V{version}__{description}.sql` 형식을 사용합니다. +- 엔티티 변경과 대응되는 DDL migration은 같은 PR에 포함합니다. +- destructive DDL은 컬럼 추가, 코드 배포, 데이터 백필, 제약 강화 순서로 나눠서 적용합니다. + +예시: + +```text +V2__add_review_image_url.sql +V3__create_hiking_record_table.sql +``` diff --git a/src/main/resources/db/migration/V1__INIT.sql b/src/main/resources/db/migration/V1__INIT.sql new file mode 100644 index 00000000..4703e6d1 --- /dev/null +++ b/src/main/resources/db/migration/V1__INIT.sql @@ -0,0 +1,388 @@ +DO $$ +BEGIN + CREATE TYPE device_type_enum AS ENUM ('ANDROID', 'IOS'); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +DO $$ +BEGIN + CREATE TYPE gender_enum AS ENUM ('FEMALE', 'MALE', 'NONE'); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +DO $$ +BEGIN + CREATE TYPE oauth_provider_enum AS ENUM ('APPLE', 'KAKAO', 'TEST'); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +DO $$ +BEGIN + CREATE TYPE onboarding_status_enum AS ENUM ('COMPLETE', 'INCOMPLETE'); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +CREATE TABLE IF NOT EXISTS users +( + id bigint generated by default as identity + primary key, + created_at timestamp(6) not null, + updated_at timestamp(6) not null, + age integer, + is_deleted boolean not null, + device_type device_type_enum not null, + email varchar(50), + fcm_token varchar(255), + gender gender_enum, + name varchar(30), + oauth_id varchar(255) not null, + oauth_provider oauth_provider_enum not null, + onboarding_status onboarding_status_enum not null, + profile_url varchar(255), + weight double precision, + nickname varchar(30), + birth_date date, + height double precision, + constraint users_pk + unique (oauth_id, oauth_provider) +); + +CREATE UNIQUE INDEX IF NOT EXISTS uk_users_active_nickname + on users (nickname) + where ((nickname IS NOT NULL) AND (is_deleted = false)); + +CREATE TABLE IF NOT EXISTS fcm_tokens +( + id bigint generated by default as identity + primary key, + created_at timestamp(6) not null, + updated_at timestamp(6) not null, + device_type device_type_enum not null, + token varchar(512) not null + constraint ukqopjyk0c1cho2ep0abxd9hi5q + unique, + user_id bigint not null +); + +CREATE INDEX IF NOT EXISTS idx_fcm_tokens_user_id + on fcm_tokens (user_id); + +CREATE TABLE IF NOT EXISTS mountains +( + id bigint generated by default as identity + primary key, + created_at timestamp(6) not null, + updated_at timestamp(6) not null, + address varchar(255) not null, + altitude double precision not null, + difficulty varchar(20) not null + constraint mountains_difficulty_check + check ((difficulty)::text = ANY + ((ARRAY ['EASY'::character varying, 'NORMAL'::character varying, 'HARD'::character varying])::text[])), + duration integer, + image_url varchar(500), + latitude double precision, + longitude double precision, + name varchar(50) not null +); + +CREATE TABLE IF NOT EXISTS amenities +( + id bigint generated by default as identity + primary key, + created_at timestamp(6) not null, + updated_at timestamp(6) not null, + direction varchar(50) not null, + type varchar(20) not null + constraint amenities_type_check + check ((type)::text = ANY + ((ARRAY ['RESTROOM'::character varying, 'INFORMATION'::character varying, 'SHELTER'::character varying, 'PARKING'::character varying, 'STORE'::character varying])::text[])), + mountain_id bigint not null + constraint fkei4md0u9m9co2vnkm7oehecvk + references mountains +); + +CREATE TABLE IF NOT EXISTS courses +( + id bigint generated by default as identity + primary key, + created_at timestamp(6) not null, + updated_at timestamp(6) not null, + difficulty varchar(20) not null + constraint courses_difficulty_check + check ((difficulty)::text = ANY + ((ARRAY ['EASY'::character varying, 'NORMAL'::character varying, 'HARD'::character varying])::text[])), + distance double precision not null, + duration integer not null, + name varchar(100) not null, + mountain_id bigint not null + constraint fkbus8p693p0o69ufkxjyr80ods + references mountains +); + +CREATE TABLE IF NOT EXISTS notifications +( + id bigint generated by default as identity + primary key, + created_at timestamp(6) not null, + updated_at timestamp(6) not null, + body varchar(500) not null, + extras jsonb, + is_read boolean not null, + read_at timestamp(6), + title varchar(100) not null, + type varchar(50) not null + constraint notifications_type_check + check ((type)::text = 'COMMUNITY_COMMENT'::text), + user_id bigint not null +); + +CREATE INDEX IF NOT EXISTS idx_notifications_user_id_created_at + on notifications (user_id asc, created_at desc); + +CREATE TABLE IF NOT EXISTS restaurant_sections +( + id bigint generated by default as identity + primary key, + created_at timestamp(6) not null, + updated_at timestamp(6) not null, + title varchar(100) not null, + mountain_id bigint not null + constraint fkgr63prvstv4ijxhsqfxkjxgkc + references mountains +); + +CREATE TABLE IF NOT EXISTS restaurants +( + id bigint generated by default as identity + primary key, + created_at timestamp(6) not null, + updated_at timestamp(6) not null, + category varchar(50), + image_url varchar(500), + name varchar(100) not null, + section_id bigint not null + constraint fkhksvos93swrecisxt6dss0iux + references restaurant_sections +); + +CREATE TABLE IF NOT EXISTS reviews +( + id bigint generated by default as identity + primary key, + created_at timestamp(6) not null, + updated_at timestamp(6) not null, + content text not null, + difficulty varchar(20) not null + constraint reviews_difficulty_check + check ((difficulty)::text = ANY + ((ARRAY ['EASY'::character varying, 'NORMAL'::character varying, 'HARD'::character varying])::text[])), + image_url varchar(500), + course_id bigint + constraint fkccbfc9u1qimejr5ll7yuxbtqs + references courses, + mountain_id bigint not null + constraint fk52hwkbpt6ygjvshk6aj1016el + references mountains, + user_id bigint not null + constraint fkcgy7qjc1r99dp117y9en6lxye + references users +); + +CREATE TABLE IF NOT EXISTS transportations +( + id bigint generated by default as identity + primary key, + created_at timestamp(6) not null, + updated_at timestamp(6) not null, + description varchar(500), + direction varchar(50) not null, + name varchar(100) not null, + type varchar(20) not null + constraint transportations_type_check + check ((type)::text = ANY + ((ARRAY ['SUBWAY'::character varying, 'BUS'::character varying, 'PARKING'::character varying])::text[])), + mountain_id bigint not null + constraint fks9mes69yi1rvrfajfrlpyatql + references mountains +); + +CREATE TABLE IF NOT EXISTS user_onboardings +( + id bigint generated by default as identity + primary key, + created_at timestamp(6) not null, + updated_at timestamp(6) not null, + user_id bigint not null + unique + constraint fk_user_onboardings_user + references users, + hiking_level varchar(20) not null + constraint user_onboardings_hiking_level_check + check ((hiking_level)::text = ANY + ((ARRAY ['BEGINNER'::character varying, 'INTERMEDIATE'::character varying, 'EXPERT'::character varying])::text[])), + exercise_type varchar(30) not null + constraint user_onboardings_exercise_type_check + check ((exercise_type)::text = ANY + ((ARRAY ['GYM'::character varying, 'CARDIO'::character varying, 'HOME_TRAINING'::character varying, 'PILATES'::character varying, 'YOGA'::character varying, 'WALKING_HIKING'::character varying, 'SPORTS'::character varying, 'CROSSFIT'::character varying, 'SWIMMING'::character varying, 'NONE'::character varying])::text[])), + exercise_frequency varchar(30) + constraint user_onboardings_exercise_frequency_check + check ((exercise_frequency IS NULL) OR ((exercise_frequency)::text = ANY + ((ARRAY ['DAILY'::character varying, 'WEEK_3_4'::character varying, 'WEEK_1_2'::character varying, 'MONTH_1_2'::character varying, 'LESS_THAN_MONTH_1'::character varying])::text[]))), + exercise_duration varchar(30) + constraint user_onboardings_exercise_duration_check + check ((exercise_duration IS NULL) OR ((exercise_duration)::text = ANY + ((ARRAY ['OVER_4H'::character varying, 'HOUR_2_4'::character varying, 'HOUR_1_2'::character varying, 'UNDER_1H'::character varying])::text[]))) +); + +CREATE TABLE IF NOT EXISTS user_notification_settings +( + id bigint generated by default as identity + primary key, + created_at timestamp(6) not null, + updated_at timestamp(6) not null, + user_id bigint not null + unique + constraint fk_user_notification_settings_user + references users, + push_notification_enabled boolean not null, + live_activity_enabled boolean not null, + voice_enabled boolean not null +); + +CREATE TABLE IF NOT EXISTS mountain_likes +( + id bigint generated by default as identity + primary key, + created_at timestamp(6) not null, + updated_at timestamp(6) not null, + user_id bigint not null + constraint fk_mountain_likes_user + references users, + mountain_id bigint not null + constraint fk_mountain_likes_mountain + references mountains, + constraint uk_mountain_likes_user_id_mountain_id + unique (user_id, mountain_id) +); + +CREATE INDEX IF NOT EXISTS idx_mountain_likes_user_id_created_at + on mountain_likes (user_id asc, created_at desc); + +CREATE TABLE IF NOT EXISTS hiking_records +( + id bigint generated by default as identity + primary key, + created_at timestamp(6) not null, + updated_at timestamp(6) not null, + mountain_id bigint not null + constraint fk_hiking_records_mountain + references mountains, + course_id bigint not null + constraint fk_hiking_records_course + references courses, + duration integer not null, + max_altitude double precision not null, + calories integer not null, + clive_image_url varchar(500), + photo_report_image_url varchar(500) +); + +CREATE TABLE IF NOT EXISTS hiking_members +( + id bigint generated by default as identity + primary key, + created_at timestamp(6) not null, + updated_at timestamp(6) not null, + hiking_record_id bigint not null + constraint fk_hiking_members_hiking_record + references hiking_records, + user_id bigint not null + constraint fk_hiking_members_user + references users, + constraint uk_hiking_member_record_user + unique (hiking_record_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_hiking_member_user_id + on hiking_members (user_id); + +CREATE TABLE IF NOT EXISTS posts +( + post_type varchar(20) not null, + id bigint generated by default as identity + primary key, + created_at timestamp(6) not null, + updated_at timestamp(6) not null, + content text, + is_deleted boolean not null, + view_count integer not null, + author_id bigint not null + references users +); + +CREATE TABLE IF NOT EXISTS comments +( + id bigint generated by default as identity + primary key, + created_at timestamp(6) not null, + updated_at timestamp(6) not null, + content text not null, + is_deleted boolean not null, + author_id bigint not null + references users, + mentioned_user_id bigint + references users, + parent_id bigint + references comments, + post_id bigint not null + references posts +); + +CREATE TABLE IF NOT EXISTS free_posts +( + title varchar(200) not null, + id bigint not null + primary key + references posts +); + +CREATE TABLE IF NOT EXISTS post_images +( + id bigint generated by default as identity + primary key, + created_at timestamp(6) not null, + updated_at timestamp(6) not null, + image_url varchar(500) not null, + is_main boolean not null, + sort_order integer not null, + post_id bigint not null + references posts +); + +CREATE TABLE IF NOT EXISTS post_likes +( + id bigint generated by default as identity + primary key, + created_at timestamp(6) not null, + updated_at timestamp(6) not null, + post_id bigint not null + references posts, + user_id bigint not null + references users, + constraint uk_post_like_post_user + unique (post_id, user_id) +); + +CREATE TABLE IF NOT EXISTS record_posts +( + id bigint not null + primary key + references posts, + hiking_record_id bigint not null + references hiking_records +);