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
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: CI

on:
pull_request:
branches: [ develop, main ]
Comment on lines +3 to +5

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

push 트리거가 빠져 있어서 CI 목표를 완전히 못 채웁니다.

현재는 PR에서만 테스트가 돌고, develop/main 직접 푸시는 이 워크플로우를 타지 않습니다. 링크된 목표가 push/PR 양쪽 자동 실행이라면 push도 같이 등록해야 합니다.

예시 수정
 on:
   pull_request:
     branches: [ develop, main ]
+  push:
+    branches: [ develop, main ]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
on:
pull_request:
branches: [ develop, main ]
on:
pull_request:
branches: [ develop, main ]
push:
branches: [ develop, main ]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/ci.yml around lines 3 - 5, 현재 워크플로우에 "on: pull_request"만
설정되어 있어 직접 푸시(push)로는 실행되지 않습니다; 수정하려면 워크플로우의 트리거 섹션(현재 "on: pull_request"와
"branches: [ develop, main ]" 설정)을 확장해 동일한 브랜치들에 대해 "push" 트리거도 추가하여 push/PR 둘 다
CI가 실행되도록 만드세요.


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
74 changes: 74 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +73 to +74

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

배포 성공 판정이 너무 이릅니다.

docker ps는 컨테이너 프로세스가 잠깐 떠 있는지만 보여 주고, 스프링 부트가 실제로 기동 완료됐는지는 보장하지 않습니다. 이번 PR 설명대로 기동에 수십 초가 걸리고 Flyway/외부 연결까지 걸려 있으니, 지금 상태에선 시작 직후 실패해도 배포가 성공으로 끝날 수 있습니다. 컨테이너 HEALTHCHECKdocker inspect, 또는 애플리케이션 readiness 확인을 추가해서 타임아웃 시 워크플로우를 실패시키는 편이 안전합니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/deploy.yml around lines 73 - 74, Current deploy step uses
`docker ps | grep hard-click-app` which only shows a running process and can
falsely mark deployment success; replace it with a robust readiness check: wait
for the container `hard-click-app` to report healthy via its Docker HEALTHCHECK
(`docker inspect` reading `.State.Health.Status`) or poll the application's
readiness HTTP endpoint inside the container (e.g., curl
http://localhost:PORT/actuator/health/readiness), retry with a timeout, and fail
the job if it doesn't become healthy within the timeout so the workflow
accurately fails on startup or migration errors.

6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY build/libs/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
Comment on lines +1 to +4

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

컨테이너를 root로 실행하고 있습니다.

현재 이미지가 기본 사용자(root)로 애플리케이션을 실행합니다. 애플리케이션 취약점이 발생했을 때 컨테이너 내부 권한이 과도하므로, 전용 비특권 사용자를 만든 뒤 USER로 전환하는 편이 안전합니다.

예시 수정
 FROM eclipse-temurin:17-jre-alpine
 WORKDIR /app
+RUN addgroup -S app && adduser -S app -G app
 COPY build/libs/*.jar app.jar
+RUN chown -R app:app /app
+USER app
 ENTRYPOINT ["java", "-jar", "app.jar"]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
COPY build/libs/*.jar app.jar
ENTRYPOINT ["java", "-jar", "app.jar"]
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app
RUN addgroup -S app && adduser -S app -G app
COPY build/libs/*.jar app.jar
RUN chown -R app:app /app
USER app
ENTRYPOINT ["java", "-jar", "app.jar"]
🧰 Tools
🪛 Trivy (0.69.3)

[error] 1-1: Image user should not be 'root'

Specify at least 1 USER command in Dockerfile with non-root user as argument

Rule: DS-0002

Learn more

(IaC/Dockerfile)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Dockerfile` around lines 1 - 4, The Dockerfile runs the app as root; create a
non-root user and switch to it before ENTRYPOINT: add steps after WORKDIR/COPY
to create a dedicated unprivileged user/group, chown the application files
(app.jar) and WORKDIR to that user, and then add USER to run the container as
that user so ENTRYPOINT ["java", "-jar", "app.jar"] executes without root
privileges; update Dockerfile symbols WORKDIR, COPY, app.jar, and ENTRYPOINT
accordingly.

Source: Linters/SAST tools

4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
36 changes: 36 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +6 to +10

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

로컬 Compose 자격 증명을 코드에 하드코딩하지 마세요.

MYSQL_PASSWORDMYSQL_ROOT_PASSWORD가 저장소에 평문으로 들어가 있습니다. 지금처럼 고정값을 커밋하면 재사용·유출 위험이 커지고, 이후 운영값과도 쉽게 혼동됩니다. 이 파일도 ${...} 또는 env_file 기반으로 바꿔서 비밀값을 저장소 밖으로 빼는 편이 안전합니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docker-compose.yml` around lines 6 - 10, Remove hardcoded credentials from
the docker-compose service environment block (MYSQL_DATABASE, MYSQL_USER,
MYSQL_PASSWORD, MYSQL_ROOT_PASSWORD) and switch to using environment variable
interpolation or an env_file; update the compose file to reference
${MYSQL_DATABASE}, ${MYSQL_USER}, ${MYSQL_PASSWORD}, ${MYSQL_ROOT_PASSWORD} (or
point to an env_file) and ensure local .env or .env.local is added to .gitignore
and contains the actual secret values for local development.

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:
Original file line number Diff line number Diff line change
@@ -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
) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@ public enum PaymentStatus {
REFUNDED,
READY,
FAILED,
CANCELED,
CANCELLED;
CANCELED;

public static PaymentStatus from(String value) {
return PaymentStatus.valueOf(value);
Comment on lines +8 to 11

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

기존 CANCELLED 저장값과의 호환성이 깨집니다.

PaymentJpaEntitypayments.status를 문자열로 그대로 읽고, MyPaymentHistoryQueryAdapter는 그 값을 PaymentStatus.from(...)로 바로 enum 변환합니다. 그래서 상수만 CANCELED로 바꾸면 DB에 남아 있는 기존 CANCELLED 데이터는 여기서 IllegalArgumentException으로 깨집니다. 이 변경은 enum rename만으로 끝낼 게 아니라, 데이터 migration으로 값을 정규화하거나 from()에서 구표기를 한시적으로 호환해야 합니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/main/java/com/wanted/backend/domain/payment/domain/model/PaymentStatus.java`
around lines 8 - 11, PaymentStatus.from currently delegates to
PaymentStatus.valueOf and will break on legacy DB values like "CANCELLED";
update from(String value) in PaymentStatus to normalize and accept legacy
spellings (e.g., trim, uppercase, map "CANCELLED" -> "CANCELED") and return the
correct enum, and/or add a migration path for existing DB rows; check usages in
PaymentJpaEntity and MyPaymentHistoryQueryAdapter to ensure they keep calling
PaymentStatus.from(...) so the compatibility mapping handles old values.

Expand Down
28 changes: 28 additions & 0 deletions src/main/java/com/wanted/backend/global/config/InfluxDbConfig.java
Original file line number Diff line number Diff line change
@@ -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);
Comment on lines +24 to +26

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

InfluxDB 클라이언트가 항상 생성돼서 테스트/로컬 기동을 깨뜨릴 수 있습니다.

지금은 INFLUX_TOKEN이 없는 환경에서도 InfluxDBClient 빈을 무조건 만들게 됩니다. 그런데 src/test/resources/application-test.yaml에는 influx.* 오버라이드가 없어서, @SpringBootTest가 컨텍스트를 띄우는 순간 여기서 실패할 가능성이 큽니다. InfluxDB가 선택 인프라라면 @ConditionalOnProperty로 감싸거나, 테스트 프로필에서는 비활성화되도록 분리하는 편이 안전합니다.

수정 예시
 import com.influxdb.client.InfluxDBClient;
 import com.influxdb.client.InfluxDBClientFactory;
 import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;

 `@Configuration`
 public class InfluxDbConfig {
@@
     `@Bean`
+    `@ConditionalOnProperty`(prefix = "influx", name = "token")
     public InfluxDBClient influxDBClient() {
         return InfluxDBClientFactory.create(url, token.toCharArray(), org, bucket);
     }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Bean
public InfluxDBClient influxDBClient() {
return InfluxDBClientFactory.create(url, token.toCharArray(), org, bucket);
`@Bean`
`@ConditionalOnProperty`(prefix = "influx", name = "token")
public InfluxDBClient influxDBClient() {
return InfluxDBClientFactory.create(url, token.toCharArray(), org, bucket);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/java/com/wanted/backend/global/config/InfluxDbConfig.java` around
lines 24 - 26, InfluxDbConfig의 influxDBClient 빈이 항상 생성되어 INFLUX_TOKEN이 없는 테스트/로컬
환경에서 실패할 수 있으니, InfluxDB가 선택적 인프라인 경우 빈 등록을 조건부로 변경하세요: InfluxDbConfig 또는
influxDBClient 메서드에 `@ConditionalOnProperty`(prefix="influx", name="enabled",
havingValue="true", matchIfMissing=false) 또는 프로파일 기반(`@Profile`("!test") 또는 별도
테스트용 구성) 어노테이션을 추가해 url/token/org/bucket 값이 없을 때 빈이 생성되지 않도록 하고, 테스트 환경에서는 해당 빈을
비활성화하거나 모킹된 대체 빈을 제공하도록 수정하세요.

}
}
38 changes: 38 additions & 0 deletions src/main/java/com/wanted/backend/global/config/RedisConfig.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,6 +27,7 @@
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
@EnableMethodSecurity
public class SecurityConfig {

private final JwtProvider jwtProvider;
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -104,4 +108,12 @@ CorsConfigurationSource corsConfigurationSource() {

return source;
}

@Bean
public RoleHierarchy roleHierarchy() {
return RoleHierarchyImpl.fromHierarchy("""
ROLE_ADMIN > ROLE_INSTRUCTOR
ROLE_INSTRUCTOR > ROLE_STUDENT
""");
}
}
Loading
Loading