From 0a07466899a303e1c38710f03ba09110d762bbcd Mon Sep 17 00:00:00 2001 From: jongjunn Date: Fri, 12 Jun 2026 22:48:15 +0900 Subject: [PATCH] =?UTF-8?q?chore:=20=EB=B0=B0=ED=8F=AC=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=20=EA=B5=AC=EC=84=B1=20(Flyway,=20Redis,=20InfluxDB,?= =?UTF-8?q?=20CI/CD,=20SecurityConfig)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 26 ++++++ .github/workflows/deploy.yml | 74 +++++++++++++++++ .gitignore | 6 ++ Dockerfile | 4 + build.gradle | 4 + docker-compose.yml | 36 +++++++++ .../service/PlayableVideoProgressReader.java | 40 +++++++++ .../payment/domain/model/PaymentStatus.java | 3 +- .../backend/global/config/InfluxDbConfig.java | 28 +++++++ .../backend/global/config/RedisConfig.java | 38 +++++++++ .../backend/global/config/SecurityConfig.java | 14 +++- src/main/resources/application.yaml | 81 +++++++++++++++++++ .../resources/db/migration/V1__baseline.sql | 8 +- src/test/resources/application-test.yaml | 3 + 14 files changed, 360 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/deploy.yml create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 src/main/java/com/wanted/backend/domain/learning_activity/application/service/PlayableVideoProgressReader.java create mode 100644 src/main/java/com/wanted/backend/global/config/InfluxDbConfig.java create mode 100644 src/main/java/com/wanted/backend/global/config/RedisConfig.java create mode 100644 src/main/resources/application.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..cc905a4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + pull_request: + branches: [ develop, main ] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Run tests + run: ./gradlew test diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..b395f51 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,74 @@ +name: Deploy to EC2 + +on: + push: + branches: [ main ] + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + cache: gradle + + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + - name: Build with Gradle + run: ./gradlew clean build -x test + + - name: Copy jar and Dockerfile to EC2 + uses: appleboy/scp-action@v0.1.7 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USERNAME }} + key: ${{ secrets.EC2_SSH_KEY }} + source: "build/libs/*.jar,Dockerfile" + target: "/home/${{ secrets.EC2_USERNAME }}/app" + + - name: Deploy on EC2 + uses: appleboy/ssh-action@v1.0.0 + with: + host: ${{ secrets.EC2_HOST }} + username: ${{ secrets.EC2_USERNAME }} + key: ${{ secrets.EC2_SSH_KEY }} + script: | + cd /home/${{ secrets.EC2_USERNAME }}/app + + docker stop hard-click-app || true + docker rm hard-click-app || true + docker rmi hard-click-app || true + + docker build -t hard-click-app . + + docker run -d \ + --name hard-click-app \ + --restart unless-stopped \ + --network host \ + -e DB_URL="${{ secrets.DB_URL }}" \ + -e DB_USERNAME="${{ secrets.DB_USERNAME }}" \ + -e DB_PASSWORD="${{ secrets.DB_PASSWORD }}" \ + -e JWT_SECRET="${{ secrets.JWT_SECRET }}" \ + -e JWT_ACCESS_EXPIRATION=1800000 \ + -e JWT_REFRESH_EXPIRATION=1209600000 \ + -e MAIL_USERNAME="${{ secrets.MAIL_USERNAME }}" \ + -e MAIL_PASSWORD="${{ secrets.MAIL_PASSWORD }}" \ + -e REDIS_HOST=localhost \ + -e REDIS_PORT=6379 \ + -e REDIS_PASSWORD="${{ secrets.REDIS_PASSWORD }}" \ + -e INFLUX_URL=http://localhost:8086 \ + -e INFLUX_TOKEN="${{ secrets.INFLUX_TOKEN }}" \ + -e INFLUX_ORG=hardclick \ + -e INFLUX_BUCKET=hardclick-metrics \ + hard-click-app + + echo "=== Deploy complete ===" + docker ps | grep hard-click-app diff --git a/.gitignore b/.gitignore index dec9109..3be697c 100644 --- a/.gitignore +++ b/.gitignore @@ -39,9 +39,15 @@ out/ ### .yaml ### *.yaml *.yml +!application.yaml !application-test.yml !application-test.yaml +!docker-compose.yml !.coderabbit.yaml +!.github/**/*.yml + +### Secrets ### +application-secret.yaml ### Logs ### logs/ *.log \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..02990ee --- /dev/null +++ b/Dockerfile @@ -0,0 +1,4 @@ +FROM eclipse-temurin:17-jre-alpine +WORKDIR /app +COPY build/libs/*.jar app.jar +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/build.gradle b/build.gradle index bbf1d34..1f19c1d 100644 --- a/build.gradle +++ b/build.gradle @@ -39,6 +39,10 @@ dependencies { runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' implementation 'org.springframework.boot:spring-boot-starter-mail' + implementation 'org.flywaydb:flyway-core' + implementation 'org.flywaydb:flyway-mysql' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + implementation 'com.influxdb:influxdb-client-java:7.2.0' } tasks.named('test') { diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..979e136 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,36 @@ +services: + mysql: + image: mysql:8.0 + container_name: hard-click-mysql + restart: unless-stopped + environment: + MYSQL_DATABASE: Hard-Click + MYSQL_USER: Hard-Click + MYSQL_PASSWORD: Hard-Click + MYSQL_ROOT_PASSWORD: Hard-Click + ports: + - "3306:3306" + volumes: + - mysql-data:/var/lib/mysql + command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci + + redis: + image: redis:7 + container_name: hardclick-redis + ports: + - "6379:6379" + volumes: + - redis-data:/data + + influxdb: + image: influxdb:2 + container_name: hardclick-influxdb + ports: + - "8086:8086" + volumes: + - influxdb-data:/var/lib/influxdb2 + +volumes: + mysql-data: + redis-data: + influxdb-data: diff --git a/src/main/java/com/wanted/backend/domain/learning_activity/application/service/PlayableVideoProgressReader.java b/src/main/java/com/wanted/backend/domain/learning_activity/application/service/PlayableVideoProgressReader.java new file mode 100644 index 0000000..15cd3c5 --- /dev/null +++ b/src/main/java/com/wanted/backend/domain/learning_activity/application/service/PlayableVideoProgressReader.java @@ -0,0 +1,40 @@ +package com.wanted.backend.domain.learning_activity.application.service; + +import com.wanted.backend.domain.learning_activity.application.port.VideoCatalogPort; +import com.wanted.backend.domain.learning_activity.domain.model.VideoAccessInfo; +import com.wanted.backend.domain.learning_activity.domain.model.VideoProgress; +import com.wanted.backend.domain.learning_activity.domain.repository.VideoProgressRepository; +import com.wanted.backend.global.exception.BusinessException; +import com.wanted.backend.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PlayableVideoProgressReader { + + private final VideoCatalogPort videoCatalogPort; + private final VideoAccessService videoAccessService; + private final VideoProgressRepository videoProgressRepository; + + public PlayableVideoProgress get(Long memberId, Long videoId) { + VideoAccessInfo accessInfo = videoCatalogPort.findByVideoId(videoId) + .orElseThrow(() -> new BusinessException(ErrorCode.VIDEO_NOT_FOUND)); + + videoAccessService.validatePlayable(memberId, accessInfo); + + VideoProgress progress = videoProgressRepository + .findByMemberIdAndVideoId(memberId, videoId) + .orElse(VideoProgress.empty(memberId, accessInfo.courseId(), videoId)); + + return new PlayableVideoProgress(accessInfo, progress); + } + + public record PlayableVideoProgress( + VideoAccessInfo accessInfo, + VideoProgress progress + ) { + } +} diff --git a/src/main/java/com/wanted/backend/domain/payment/domain/model/PaymentStatus.java b/src/main/java/com/wanted/backend/domain/payment/domain/model/PaymentStatus.java index 7a03051..3b627c5 100644 --- a/src/main/java/com/wanted/backend/domain/payment/domain/model/PaymentStatus.java +++ b/src/main/java/com/wanted/backend/domain/payment/domain/model/PaymentStatus.java @@ -5,8 +5,7 @@ public enum PaymentStatus { REFUNDED, READY, FAILED, - CANCELED, - CANCELLED; + CANCELED; public static PaymentStatus from(String value) { return PaymentStatus.valueOf(value); diff --git a/src/main/java/com/wanted/backend/global/config/InfluxDbConfig.java b/src/main/java/com/wanted/backend/global/config/InfluxDbConfig.java new file mode 100644 index 0000000..31c3485 --- /dev/null +++ b/src/main/java/com/wanted/backend/global/config/InfluxDbConfig.java @@ -0,0 +1,28 @@ +package com.wanted.backend.global.config; + +import com.influxdb.client.InfluxDBClient; +import com.influxdb.client.InfluxDBClientFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class InfluxDbConfig { + + @Value("${influx.url}") + private String url; + + @Value("${influx.token}") + private String token; + + @Value("${influx.org}") + private String org; + + @Value("${influx.bucket}") + private String bucket; + + @Bean + public InfluxDBClient influxDBClient() { + return InfluxDBClientFactory.create(url, token.toCharArray(), org, bucket); + } +} diff --git a/src/main/java/com/wanted/backend/global/config/RedisConfig.java b/src/main/java/com/wanted/backend/global/config/RedisConfig.java new file mode 100644 index 0000000..dabce65 --- /dev/null +++ b/src/main/java/com/wanted/backend/global/config/RedisConfig.java @@ -0,0 +1,38 @@ +package com.wanted.backend.global.config; + +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import java.time.Duration; + +@Configuration +@EnableCaching +public class RedisConfig { + + @Bean + public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) { + RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofMinutes(10)) + .serializeKeysWith( + RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) + .serializeValuesWith( + RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())); + + return RedisCacheManager.builder(connectionFactory) + .cacheDefaults(config) + .build(); + } + + @Bean + public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) { + return new StringRedisTemplate(connectionFactory); + } +} diff --git a/src/main/java/com/wanted/backend/global/config/SecurityConfig.java b/src/main/java/com/wanted/backend/global/config/SecurityConfig.java index b48c1e7..838f015 100644 --- a/src/main/java/com/wanted/backend/global/config/SecurityConfig.java +++ b/src/main/java/com/wanted/backend/global/config/SecurityConfig.java @@ -8,6 +8,9 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; +import org.springframework.security.access.hierarchicalroles.RoleHierarchy; +import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; @@ -24,6 +27,7 @@ @Configuration @EnableWebSecurity @RequiredArgsConstructor +@EnableMethodSecurity public class SecurityConfig { private final JwtProvider jwtProvider; @@ -66,7 +70,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/v3/api-docs/**" ).permitAll() .requestMatchers(HttpMethod.GET, "/uploads/**").permitAll() - .requestMatchers(HttpMethod.GET, "/api/courses", "/api/courses/*","/api/courses/*/reviews").permitAll() + .requestMatchers(HttpMethod.GET, "/api/courses", "/api/courses/*", "/api/courses/*/reviews").permitAll() .requestMatchers(HttpMethod.POST, "/api/courses").hasRole("INSTRUCTOR") .requestMatchers(HttpMethod.POST, "/api/courses/lessons/*/video").hasRole("INSTRUCTOR") .requestMatchers(HttpMethod.PATCH, "/api/courses/*", "/api/courses/*/status").hasRole("INSTRUCTOR") @@ -104,4 +108,12 @@ CorsConfigurationSource corsConfigurationSource() { return source; } + + @Bean + public RoleHierarchy roleHierarchy() { + return RoleHierarchyImpl.fromHierarchy(""" + ROLE_ADMIN > ROLE_INSTRUCTOR + ROLE_INSTRUCTOR > ROLE_STUDENT + """); + } } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml new file mode 100644 index 0000000..c4a7ef4 --- /dev/null +++ b/src/main/resources/application.yaml @@ -0,0 +1,81 @@ +spring: + application: + name: hard-click + + datasource: + url: ${DB_URL:jdbc:mysql://localhost:3306/Hard-Click} + driver-class-name: com.mysql.cj.jdbc.Driver + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + + jpa: + hibernate: + ddl-auto: validate + show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.MySQLDialect + format_sql: true + + flyway: + enabled: true + locations: classpath:db/migration + baseline-on-migrate: true + encoding: UTF-8 + + mail: + host: smtp.gmail.com + port: 587 + username: ${MAIL_USERNAME} + password: ${MAIL_PASSWORD} + properties: + mail: + smtp: + auth: true + starttls: + enable: true + + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD} + + config: + import: optional:classpath:application-secret.yaml + +jwt: + secret: ${JWT_SECRET} + access-token-expiration-ms: ${JWT_ACCESS_EXPIRATION:1800000} + refresh-token-expiration-ms: ${JWT_REFRESH_EXPIRATION:1209600000} + +server: + port: 8080 + +identity: + email: + allowed-domain: gmail.com + profile-image: + upload-dir: uploads/profiles + url: /uploads/profiles + max-size: 5242880 + +community: + image: + post-dir: uploads/posts + post-url: /uploads/posts + comment-dir: uploads/comments + comment-url: /uploads/comments + max-size: 5242880 + +logging: + config: classpath:logback-spring.xml + level: + com.wanted.backend: INFO + org.hibernate.SQL: WARN + +influx: + url: ${INFLUX_URL:http://localhost:8086} + token: ${INFLUX_TOKEN} + org: ${INFLUX_ORG:hardclick} + bucket: ${INFLUX_BUCKET:hardclick-metrics} diff --git a/src/main/resources/db/migration/V1__baseline.sql b/src/main/resources/db/migration/V1__baseline.sql index a738daa..b2b0388 100644 --- a/src/main/resources/db/migration/V1__baseline.sql +++ b/src/main/resources/db/migration/V1__baseline.sql @@ -1,4 +1,5 @@ -/*!40101 SET @saved_cs_client = @@character_set_client */; +SET FOREIGN_KEY_CHECKS=0; +/*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; CREATE TABLE `comments` ( `comment_id` bigint NOT NULL AUTO_INCREMENT, @@ -94,6 +95,8 @@ CREATE TABLE `email_verifications` ( `expires_at` datetime(6) NOT NULL, `is_verified` bit(1) NOT NULL, `purpose` enum('ACCOUNT_LOCK','PASSWORD_RESET','SIGNUP') COLLATE utf8mb4_unicode_ci NOT NULL, + `status` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING', + `used` bit(1) NOT NULL DEFAULT b'0', `verification_token` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL, `verified_at` datetime(6) DEFAULT NULL, PRIMARY KEY (`verification_id`), @@ -346,7 +349,7 @@ CREATE TABLE `video_progress` ( /*!40101 SET character_set_client = @saved_cs_client */; /*!40101 SET @saved_cs_client = @@character_set_client */; /*!50503 SET character_set_client = utf8mb4 */; -CREATE TABLE `videos` ( +CREATE TABLE `video` ( `video_id` bigint NOT NULL, `curriculum_id` bigint NOT NULL, `duration_seconds` int NOT NULL, @@ -365,4 +368,5 @@ CREATE TABLE `view_logs` ( PRIMARY KEY (`view_log_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; /*!40101 SET character_set_client = @saved_cs_client */; +SET FOREIGN_KEY_CHECKS=1; diff --git a/src/test/resources/application-test.yaml b/src/test/resources/application-test.yaml index 0dd27fa..1eb28b5 100644 --- a/src/test/resources/application-test.yaml +++ b/src/test/resources/application-test.yaml @@ -31,6 +31,9 @@ spring: ddl-auto: create-drop show-sql: true # 테스트에서는 SQL 로그 활성화 + flyway: + enabled: false + # 테스트용 SQL 초기화 설정 sql: init: