Skip to content
Merged
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
13 changes: 11 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -117,13 +117,22 @@ jobs:
GOOGLE_OAUTH_CLIENT_SECRET: ${{ secrets.GOOGLE_OAUTH_CLIENT_SECRET }}
APP_OAUTH2_REDIRECT_SUCCESS_URL: ${{ secrets.OAUTH2_REDIRECT_SUCCESS_URL }}
APP_OAUTH2_EXTRA_INFO_URL: ${{ secrets.EXTRA_INFO_URL }}
FIREBASE_SERVICE_ACCOUNT_PATH: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_PATH }}
FIREBASE_SERVICE_ACCOUNT_JSON: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_JSON }}
AWS_S3_BUCKET: ${{ secrets.AWS_S3_BUCKET }}
AWS_S3_REGION: ${{ secrets.AWS_S3_REGION }}
AWS_S3_ACCESS_KEY: ${{ secrets.AWS_S3_ACCESS_KEY }}
AWS_S3_SECRET_KEY: ${{ secrets.AWS_S3_SECRET_KEY }}
GEMINI_PROJECT_ID: ${{ secrets.GEMINI_PROJECT_ID }}
GEMINI_ENABLED: ${{ secrets.GEMINI_ENABLED }}
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USERNAME }}
key: ${{ secrets.EC2_SSH_KEY }}
envs: DATABASE_HOST,DATABASE_USERNAME,DATABASE_PASSWORD,APP_JWT_SECRET,APP_JWT_ACCESS_TOKEN_EXPIRATION_SECONDS,APP_JWT_REFRESH_TOKEN_EXPIRATION_SECONDS,APP_OAUTH2_REDIRECT_SUCCESS_URL,APP_OAUTH2_EXTRA_INFO_URL,GOOGLE_CLIENT_ID,GOOGLE_OAUTH_CLIENT_SECRET,FIREBASE_SERVICE_ACCOUNT_PATH
envs: DATABASE_HOST,DATABASE_USERNAME,DATABASE_PASSWORD,APP_JWT_SECRET,APP_JWT_ACCESS_TOKEN_EXPIRATION_SECONDS,APP_JWT_REFRESH_TOKEN_EXPIRATION_SECONDS,APP_OAUTH2_REDIRECT_SUCCESS_URL,APP_OAUTH2_EXTRA_INFO_URL,GOOGLE_CLIENT_ID,GOOGLE_OAUTH_CLIENT_SECRET,FIREBASE_SERVICE_ACCOUNT_JSON,AWS_S3_BUCKET,AWS_S3_REGION,AWS_S3_ACCESS_KEY,AWS_S3_SECRET_KEY,GEMINI_PROJECT_ID,GEMINI_ENABLED
script: |
mkdir -p /home/ubuntu/app/secrets
echo "$FIREBASE_SERVICE_ACCOUNT_JSON" > /home/ubuntu/app/secrets/firebase-service-account.json
export FIREBASE_SERVICE_ACCOUNT_PATH=/app/secrets/firebase-service-account.json
Comment on lines +133 to +135

Copy link
Copy Markdown

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

Firebase 시크릿 파일은 비어있는 값일 때 생성하지 말고 권한을 제한해주세요.

Line 133-135은 FIREBASE_SERVICE_ACCOUNT_JSON이 비어도 파일을 생성해 optional 동작을 깨뜨립니다(빈 JSON 파일 마운트). 또한 서비스 계정 파일 권한 제한이 없어 민감정보 노출 위험이 있습니다.

🔧 제안 수정안
-            mkdir -p /home/ubuntu/app/secrets
-            echo "$FIREBASE_SERVICE_ACCOUNT_JSON" > /home/ubuntu/app/secrets/firebase-service-account.json
-            export FIREBASE_SERVICE_ACCOUNT_PATH=/app/secrets/firebase-service-account.json
+            install -d -m 700 /home/ubuntu/app/secrets
+            if [ -n "${FIREBASE_SERVICE_ACCOUNT_JSON:-}" ]; then
+              printf '%s' "$FIREBASE_SERVICE_ACCOUNT_JSON" > /home/ubuntu/app/secrets/firebase-service-account.json
+              chmod 600 /home/ubuntu/app/secrets/firebase-service-account.json
+              export FIREBASE_SERVICE_ACCOUNT_PATH=/app/secrets/firebase-service-account.json
+            else
+              rm -f /home/ubuntu/app/secrets/firebase-service-account.json
+              unset FIREBASE_SERVICE_ACCOUNT_PATH
+            fi
📝 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
mkdir -p /home/ubuntu/app/secrets
echo "$FIREBASE_SERVICE_ACCOUNT_JSON" > /home/ubuntu/app/secrets/firebase-service-account.json
export FIREBASE_SERVICE_ACCOUNT_PATH=/app/secrets/firebase-service-account.json
install -d -m 700 /home/ubuntu/app/secrets
if [ -n "${FIREBASE_SERVICE_ACCOUNT_JSON:-}" ]; then
printf '%s' "$FIREBASE_SERVICE_ACCOUNT_JSON" > /home/ubuntu/app/secrets/firebase-service-account.json
chmod 600 /home/ubuntu/app/secrets/firebase-service-account.json
export FIREBASE_SERVICE_ACCOUNT_PATH=/app/secrets/firebase-service-account.json
else
rm -f /home/ubuntu/app/secrets/firebase-service-account.json
unset FIREBASE_SERVICE_ACCOUNT_PATH
fi
🤖 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/release.yml around lines 133 - 135, Do not create an empty
Firebase service account file and restrict its permissions: check that the
environment variable FIREBASE_SERVICE_ACCOUNT_JSON is non-empty before running
mkdir/echo to write /home/ubuntu/app/secrets/firebase-service-account.json (skip
creation if empty), and after writing the file set restrictive permissions
(e.g., chmod 600 or chown to the deploy user) and then export
FIREBASE_SERVICE_ACCOUNT_PATH; reference the variables/paths
FIREBASE_SERVICE_ACCOUNT_JSON, FIREBASE_SERVICE_ACCOUNT_PATH and the target file
/home/ubuntu/app/secrets/firebase-service-account.json when making the change.

curl -fsSL https://raw.githubusercontent.com/JECT-Study/JECT2-4th-Server/main/scripts/setup-docker-caddy.sh \
| bash
curl -fsSL https://raw.githubusercontent.com/JECT-Study/JECT2-4th-Server/main/scripts/deploy.sh \
Expand Down
58 changes: 49 additions & 9 deletions scripts/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -107,15 +107,55 @@ fi

# 새 컨테이너 실행 (트래픽 전환 전까지 Docker 기본 bridge에만 둔다)
log "새 컨테이너($NEW_CONTAINER) 실행 중"
docker run -d --name "$NEW_CONTAINER" \
-e SPRING_PROFILES_ACTIVE=prod \
-e DATABASE_HOST \
-e DATABASE_USERNAME \
-e DATABASE_PASSWORD \
-e APP_JWT_SECRET \
-e APP_JWT_ACCESS_TOKEN_EXPIRATION_SECONDS \
-e APP_JWT_REFRESH_TOKEN_EXPIRATION_SECONDS \
"$IMAGE" >/dev/null

# Docker run 옵션 구성
DOCKER_OPTS=(
-d --name "$NEW_CONTAINER"
-e SPRING_PROFILES_ACTIVE=prod
-e DATABASE_HOST
-e DATABASE_USERNAME
-e DATABASE_PASSWORD
-e APP_JWT_SECRET
-e APP_JWT_ACCESS_TOKEN_EXPIRATION_SECONDS
-e APP_JWT_REFRESH_TOKEN_EXPIRATION_SECONDS
-e APP_OAUTH2_REDIRECT_SUCCESS_URL
-e APP_OAUTH2_EXTRA_INFO_URL
-e GOOGLE_CLIENT_ID
-e GOOGLE_OAUTH_CLIENT_SECRET
)

# Firebase + GCP 서비스 계정 설정 (선택적)
# 이 파일은 Firebase와 Vertex AI(Gemini) 인증에 모두 사용됨
if [ -f "/home/ubuntu/app/secrets/firebase-service-account.json" ]; then
DOCKER_OPTS+=(
-v /home/ubuntu/app/secrets/firebase-service-account.json:/app/secrets/firebase-service-account.json:ro
-e FIREBASE_SERVICE_ACCOUNT_PATH=/app/secrets/firebase-service-account.json
-e GOOGLE_APPLICATION_CREDENTIALS=/app/secrets/firebase-service-account.json
)
log "Firebase + GCP 서비스 계정 마운트됨"
fi

# S3 설정 (선택적)
if [ -n "${AWS_S3_BUCKET:-}" ]; then
DOCKER_OPTS+=(
-e AWS_S3_BUCKET
-e AWS_S3_REGION
-e AWS_S3_ACCESS_KEY
-e AWS_S3_SECRET_KEY
)
log "S3 설정 추가됨"
fi

# Gemini 설정 (선택적)
if [ -n "${GEMINI_PROJECT_ID:-}" ]; then
DOCKER_OPTS+=(
-e GEMINI_PROJECT_ID
-e GEMINI_ENABLED
)
log "Gemini 설정 추가됨"
fi

docker run "${DOCKER_OPTS[@]}" "$IMAGE" >/dev/null

# 헬스체크 (docker inspect로 healthy 상태 확인)
# Docker health status가 start-period 직후 일시적으로 unhealthy가 될 수 있어
Expand Down
5 changes: 2 additions & 3 deletions src/main/java/com/ject/vs/ai/config/GeminiConfig.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.ject.vs.ai.config;

import com.google.cloud.vertexai.VertexAI;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
Expand All @@ -10,10 +11,8 @@
public class GeminiConfig {

@Bean
@ConditionalOnProperty(name = "gemini.enabled", havingValue = "true")
public VertexAI vertexAI(GeminiProperties properties) {
if (!properties.enabled() || properties.projectId() == null || properties.projectId().isBlank()) {
return null;
}
return new VertexAI(properties.projectId(), properties.location());
}
}
6 changes: 3 additions & 3 deletions src/main/java/com/ject/vs/ai/port/AiInsightService.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,20 @@
@Slf4j
public class AiInsightService implements AiInsightUseCase {

private final VertexAI vertexAI;
private final Optional<VertexAI> vertexAI;
private final GeminiProperties properties;

@Override
public Optional<AiInsightResult> generateVoteInsight(VoteInsightRequest request) {
if (vertexAI == null || !properties.enabled()) {
if (vertexAI.isEmpty() || !properties.enabled()) {
log.warn("AI insight generation is disabled or Gemini is not configured");
return Optional.empty();
}

try {
String prompt = buildPrompt(request);

GenerativeModel model = new GenerativeModel(properties.model(), vertexAI);
GenerativeModel model = new GenerativeModel(properties.model(), vertexAI.get());
GenerateContentResponse response = model.generateContent(prompt);
String responseText = ResponseHandler.getText(response);

Expand Down
4 changes: 2 additions & 2 deletions src/main/java/com/ject/vs/config/S3Config.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package com.ject.vs.config;

import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
Expand All @@ -11,7 +11,7 @@

@Configuration
@EnableConfigurationProperties(S3Properties.class)
@ConditionalOnProperty(prefix = "aws.s3", name = "bucket")
@ConditionalOnExpression("!'${aws.s3.bucket:}'.isEmpty()")
public class S3Config {

@Bean
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import software.amazon.awssdk.services.s3.S3Client;

@RestController
@RequestMapping("/api/images")
@RequiredArgsConstructor
@ConditionalOnBean(ImageService.class)
@ConditionalOnBean(S3Client.class)
public class ImageController {

private final ImageService imageService;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
Expand All @@ -22,7 +23,13 @@ public class GuestFreeVoteController {

@Operation(summary = "잔여 무료 투표권 조회", description = "비회원의 잔여 무료 투표권 수를 조회합니다. 회원은 remainingFreeVotes가 null로 응답됩니다.")
@GetMapping("/free-votes")
public FreeVotesResponse getFreeVotes(@Parameter(hidden = true) @AnonymousId String anonymousId) {
public FreeVotesResponse getFreeVotes(
@AuthenticationPrincipal Long userId,
@Parameter(hidden = true) @AnonymousId String anonymousId) {
// 회원은 무료 투표권 제한이 없으므로 null로 응답
if (userId != null) {
return new FreeVotesResponse(null, null);
}
return new FreeVotesResponse(
guestFreeVoteService.remaining(anonymousId),
GuestFreeVote.totalFreeVotes()
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.ject.vs.vote.adapter.web.dto;

public record FreeVotesResponse(int remainingFreeVotes, int totalFreeVotes) {
public record FreeVotesResponse(Integer remainingFreeVotes, Integer totalFreeVotes) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,11 @@ public record GenderDistributionResponse(
public record GenderDetail(long count, int ratio) {
}

public record AgeDistributionResponse(String ageGroup, int ratio, boolean isHighlighted) {
public record AgeDistributionResponse(String ageGroup, int ratio, boolean isMyGroup) {
}

static InsightResponse from(Insight insight) {
if (insight == null) return null;
if (insight.locked()) return new InsightResponse(true, null, null, null, null);

GenderDistributionResponse gender = null;
if (insight.genderDistribution() != null) {
Expand All @@ -72,11 +71,11 @@ static InsightResponse from(Insight insight) {
List<AgeDistributionResponse> ages = null;
if (insight.ageDistribution() != null) {
ages = insight.ageDistribution().stream()
.map(a -> new AgeDistributionResponse(a.ageGroup(), a.ratio(), a.isHighlighted()))
.map(a -> new AgeDistributionResponse(a.ageGroup(), a.ratio(), a.isHighlighted())) // UseCase의 isHighlighted를 API 스펙의 isMyGroup으로 매핑
.toList();
}

return new InsightResponse(false, insight.scope() != null ? insight.scope().name() : null,
return new InsightResponse(insight.locked(), insight.scope() != null ? insight.scope().name() : null,
insight.selectionCount(), gender, ages);
}
}
Expand Down
17 changes: 17 additions & 0 deletions src/main/resources/application-local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,20 @@ management:
endpoint:
health:
show-details: never

# Firebase 푸시 알림 (선택적 - 파일이 없으면 자동 비활성화)
firebase:
service-account-path: secret/firebase-service-account.json

# Gemini AI 인사이트
gemini:
project-id: jectvsserver
enabled: true

# AWS S3 이미지 업로드
aws:
s3:
bucket: ject-vs-images
region: ap-southeast-2
access-key: ${AWS_S3_ACCESS_KEY}
secret-key: ${AWS_S3_SECRET_KEY}
Comment on lines +61 to +72

Copy link
Copy Markdown

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

local 프로필에서 외부 연동을 기본 활성화하면 “의존성 없이 실행” 목표와 충돌합니다.

Line 61-64(gemini.enabled: true)와 Line 67-72(고정 bucket)는 로컬 기본 실행 시 외부 연동을 사실상 강제합니다. 기본값은 비활성/빈 값으로 두고, 필요 시 환경변수로만 켜는 쪽이 안전합니다.

🔧 제안 수정안
 gemini:
-  project-id: jectvsserver
-  enabled: true
+  enabled: ${GEMINI_ENABLED:false}
+  project-id: ${GEMINI_PROJECT_ID:}

 aws:
   s3:
-    bucket: ject-vs-images
-    region: ap-southeast-2
-    access-key: ${AWS_S3_ACCESS_KEY}
-    secret-key: ${AWS_S3_SECRET_KEY}
+    bucket: ${AWS_S3_BUCKET:}
+    region: ${AWS_S3_REGION:ap-southeast-2}
+    access-key: ${AWS_S3_ACCESS_KEY:}
+    secret-key: ${AWS_S3_SECRET_KEY:}
🤖 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/resources/application-local.yml` around lines 61 - 72, The local
config currently enables external integrations by default; change gemini.enabled
to false (and remove or blank gemini.project-id) and clear or set aws.s3.*
values (aws.s3.bucket, aws.s3.region, aws.s3.access-key, aws.s3.secret-key) to
empty/default placeholders so local profile does not require external services;
ensure these values are only enabled via environment variables or explicit
overrides rather than hardcoded defaults.

Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ class 회원_참여자_결과 {
.andExpect(jsonPath("$.insight.genderDistribution.male.ratio").value(38))
.andExpect(jsonPath("$.insight.genderDistribution.highlightedGender").value("FEMALE"))
.andExpect(jsonPath("$.insight.ageDistribution[0].ageGroup").value("20s"))
.andExpect(jsonPath("$.insight.ageDistribution[0].isHighlighted").value(true))
.andExpect(jsonPath("$.insight.ageDistribution[0].isMyGroup").value(true))
.andExpect(jsonPath("$.aiInsight.available").value(true))
.andExpect(jsonPath("$.aiInsight.headline").exists());
}
Expand Down Expand Up @@ -223,8 +223,8 @@ class 회원_미참여자_결과 {
.andExpect(jsonPath("$.myVote.selectedOptionId").doesNotExist())
.andExpect(jsonPath("$.insight.scope").value("TOTAL"))
.andExpect(jsonPath("$.insight.genderDistribution.highlightedGender").value("FEMALE"))
.andExpect(jsonPath("$.insight.ageDistribution[0].isHighlighted").value(false))
.andExpect(jsonPath("$.insight.ageDistribution[1].isHighlighted").value(true))
.andExpect(jsonPath("$.insight.ageDistribution[0].isMyGroup").value(false))
.andExpect(jsonPath("$.insight.ageDistribution[1].isMyGroup").value(true))
.andExpect(jsonPath("$.aiInsight.available").value(false))
.andExpect(jsonPath("$.aiInsight.headline").doesNotExist());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ class getResult {
given(voteParticipationRepository.findByVoteIdAndUserId(1L, 1L)).willReturn(Optional.empty());
given(voteParticipationRepository.findGenderDistributionByVote(1L)).willReturn(List.of());
given(voteParticipationRepository.findAllUserIdsByVoteId(1L)).willReturn(List.of());
given(userRepository.findById(1L)).willReturn(Optional.empty());
given(userRepository.findAllById(List.of())).willReturn(List.of());

VoteResultDetail result = service.getResult(1L, 1L);

Expand Down
11 changes: 11 additions & 0 deletions src/test/resources/application-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,14 @@ management:
endpoint:
health:
show-details: never

# 테스트 환경에서 S3, Gemini 비활성화
aws:
s3:
bucket: ~

gemini:
enabled: false
project-id: test-project
location: us-central1
model: gemini-1.5-flash
Loading