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
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,17 @@ REALTIME_LOG_LEVEL=info

# Backend Core (Spring)
BACKEND_PORT=38010
SPRING_PROFILES_ACTIVE=local
# JWT/암호화는 base64 32바이트. 운영은 반드시 GitHub Secrets(JWT_SECRET, ENCRYPTION_KEY)로 덮어쓸 것.
JWT_SECRET=c3RhY2t1cC1zcHJpbnQxLWxvY2FsLWp3dC1zZWNyZXQtMzItYnl0ZXMtbG9uZw==
ENCRYPTION_KEY=c3RhY2t1cC1zcHJpbnQxLWVuY3J5cHRpb24ta2V5QUI=
# AI 서버와 공유. 운영은 GitHub Secrets(CORE_INTERNAL_API_KEY)로 덮어쓸 것.
CORE_INTERNAL_API_KEY=local-development-internal-api-key
# GitHub OAuth (운영은 GitHub Secrets(GH_OAUTH_CLIENT_ID/SECRET)로 덮어쓸 것)
GITHUB_OAUTH_CLIENT_ID=
GITHUB_OAUTH_CLIENT_SECRET=
GITHUB_OAUTH_REDIRECT_URI=http://localhost:5173/auth/callback
CORS_ALLOWED_ORIGINS=*

# NGINX
FRONTEND_PORT=38000
63 changes: 45 additions & 18 deletions .github/workflows/deploy-app.yml
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
name: STACK-UP deploy ana-server

# dev 브랜치가 업데이트되면 AI서버와 실시간서버를 빌드하고 배포
# 상우 원격컴에서만 작동하니 클라우드로 옮기면 삭제해야 함
# 백엔드 코어랑 프론트는 아직 도커에 정의되지 않았으니.. 그때 이어서 해야 함
# dev 브랜치 업데이트 시 AI / RealTime / Backend(Core) 를 빌드해 self-hosted runner 에서 재시작.
# 상우 원격컴 전용 — 클라우드 이관 시 워크플로우 재설계 필요.

on:
push:
branches: [dev]
paths:
- "ai/**"
- "realtime/**"
- "backend/**"
- "docker-compose.yml"
- "infra/**"
- ".github/workflows/deploy-app.yml"
Expand All @@ -22,7 +22,7 @@ concurrency:
jobs:
deploy:
runs-on: self-hosted
timeout-minutes: 20
timeout-minutes: 30

env:
DEPLOY_DIR: /home/stackup/stackup
Expand All @@ -42,41 +42,68 @@ jobs:
--exclude='**/.venv' \
--exclude='**/node_modules' \
"$GITHUB_WORKSPACE/" "$DEPLOY_DIR/"
# 5/22 시점 임시 만든 docker-compose.override.yml 은 backend 가 정식 compose 로 들어와서 불필요.
# rsync --delete 가 자동 제거하지만, 안전하게 명시적 제거.
rm -f "$DEPLOY_DIR/docker-compose.override.yml"

- name: Ensure .env exists and contains current LLM_API_KEY
- name: Ensure .env exists and inject Secrets
env:
# GitHub Repository Secrets. 비어있으면 application-local.yml / docker-compose.yml 의 dev default 가 적용됨.
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
JWT_SECRET: ${{ secrets.JWT_SECRET }}
ENCRYPTION_KEY: ${{ secrets.ENCRYPTION_KEY }}
CORE_INTERNAL_API_KEY: ${{ secrets.CORE_INTERNAL_API_KEY }}
GITHUB_OAUTH_CLIENT_ID: ${{ secrets.GH_OAUTH_CLIENT_ID }}
GITHUB_OAUTH_CLIENT_SECRET: ${{ secrets.GH_OAUTH_CLIENT_SECRET }}
run: |
cd "$DEPLOY_DIR"
if [ ! -f .env ]; then
cp .env.example .env
fi
# Write LLM_API_KEY via a temp file to keep the secret off the
# process command line.
tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT
awk -v key="$LLM_API_KEY" '
BEGIN { written = 0 }
/^LLM_API_KEY=/ { print "LLM_API_KEY=" key; written = 1; next }
{ print }
END { if (!written) print "LLM_API_KEY=" key }
' .env > "$tmp"
mv "$tmp" .env
chmod 600 .env

# 같은 키를 .env 에 upsert. 값이 비어있으면 skip (기존 값 유지).
upsert_env() {
local key="$1"
local value="$2"
if [ -z "$value" ]; then
echo " skip $key (secret 미설정)"
return 0
fi
local tmp
tmp=$(mktemp)
awk -v k="$key" -v v="$value" '
BEGIN { written = 0 }
$0 ~ ("^" k "=") { print k "=" v; written = 1; next }
{ print }
END { if (!written) print k "=" v }
' .env > "$tmp"
mv "$tmp" .env
chmod 600 .env
echo " upserted $key"
}

upsert_env "LLM_API_KEY" "$LLM_API_KEY"
upsert_env "JWT_SECRET" "$JWT_SECRET"
upsert_env "ENCRYPTION_KEY" "$ENCRYPTION_KEY"
upsert_env "CORE_INTERNAL_API_KEY" "$CORE_INTERNAL_API_KEY"
upsert_env "GITHUB_OAUTH_CLIENT_ID" "$GITHUB_OAUTH_CLIENT_ID"
upsert_env "GITHUB_OAUTH_CLIENT_SECRET" "$GITHUB_OAUTH_CLIENT_SECRET"

- name: Build and restart app services
run: |
cd "$DEPLOY_DIR"
docker compose up -d --build ai realtime
docker compose up -d --build ai realtime backend

- name: Wait for healthy
run: |
cd "$DEPLOY_DIR"
set +e
rc=0
for svc in ai realtime; do
for svc in ai realtime backend; do
container="stackup-$svc"
ok=0
# backend 는 Spring 부팅이 길어 36*5=180s 까지 대기.
for i in $(seq 1 36); do
h=$(docker inspect --format '{{ .State.Health.Status }}' "$container" 2>/dev/null || echo "missing")
echo " [$i] $container: $h"
Expand Down
4 changes: 4 additions & 0 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ RUN ./gradlew bootJar -x test --no-daemon
# Run stage
FROM eclipse-temurin:21-jre-jammy
WORKDIR /app
# curl 은 docker-compose 의 healthcheck (/actuator/health) 에서 사용.
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /home/gradle/src/build/libs/stackup-0.0.1-SNAPSHOT.jar app.jar

EXPOSE 38010
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,15 +30,18 @@ public class SecurityConfig {

private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final InternalApiKeyAuthenticationFilter internalApiKeyAuthenticationFilter;
private final StreamTokenAuthenticationFilter streamTokenAuthenticationFilter;
private final CorsProperties corsProperties;

public SecurityConfig(
JwtAuthenticationFilter jwtAuthenticationFilter,
InternalApiKeyAuthenticationFilter internalApiKeyAuthenticationFilter,
StreamTokenAuthenticationFilter streamTokenAuthenticationFilter,
CorsProperties corsProperties
) {
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
this.internalApiKeyAuthenticationFilter = internalApiKeyAuthenticationFilter;
this.streamTokenAuthenticationFilter = streamTokenAuthenticationFilter;
this.corsProperties = corsProperties;
}

Expand Down Expand Up @@ -71,6 +74,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
.anyRequest().permitAll()
)
.addFilterBefore(internalApiKeyAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(streamTokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package com.stackup.stackup.common.security;

import com.stackup.stackup.common.exception.ApiErrorCode;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

// 인증 처리 전용
@Component
public class StreamTokenAuthenticationFilter extends OncePerRequestFilter {

private static final Logger log = LoggerFactory.getLogger(StreamTokenAuthenticationFilter.class);
private static final String STREAM_PATH_PREFIX = "/api/stream/";
private static final String TOKEN_PARAM = "token";

private final StreamTokenProvider streamTokenProvider;

public StreamTokenAuthenticationFilter(StreamTokenProvider streamTokenProvider) {
this.streamTokenProvider = streamTokenProvider;
}

@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain chain
) throws ServletException, IOException {
if (!request.getRequestURI().startsWith(STREAM_PATH_PREFIX)) {
chain.doFilter(request, response);
return;
}
String token = request.getParameter(TOKEN_PARAM);
if (token == null || token.isBlank()) {
// query token 없으면 일반 JWT 필터가 Authorization 헤더로 처리하도록 통과
chain.doFilter(request, response);
return;
}

try {
Long userId = streamTokenProvider.getUserId(token);
UserPrincipal principal = new UserPrincipal(
userId, null, List.of(new SimpleGrantedAuthority("ROLE_USER"))
);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(principal, token, principal.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
} catch (RuntimeException exception) {
request.setAttribute(
JwtAuthenticationFilter.AUTH_ERROR_CODE_ATTRIBUTE,
ApiErrorCode.AUTH_INVALID_TOKEN
);
SecurityContextHolder.clearContext();
log.warn("stream-token authentication failed. uri={}", request.getRequestURI());
}
chain.doFilter(request, response);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,21 @@ private void applyFailed(AnalyzedDocument doc, AnalysisCallbackPayload payload)
private void publishSse(AnalyzedDocument doc, AnalysisCallbackPayload payload) {
SseEventType type = doc.getRepository() != null ? SseEventType.REPO_STATE : SseEventType.DOC_STATE;
sseEventPublisher.publishToDocument(doc.getId(), type, payload);
// 프론트가 documentId 를 모르고도 /api/stream/me 로 분석 진행을 받을 수 있게 user 채널에도 push.
Long userId = resolveOwnerUserId(doc);
if (userId != null) {
sseEventPublisher.publishToUser(userId, type, payload);
}
}

private Long resolveOwnerUserId(AnalyzedDocument doc) {
if (doc.getResume() != null && doc.getResume().getUser() != null) {
return doc.getResume().getUser().getId();
}
if (doc.getRepository() != null && doc.getRepository().getUser() != null) {
return doc.getRepository().getUser().getId();
}
return null;
}

private boolean isProcessed(String messageId) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package com.stackup.stackup.document.application;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.stackup.stackup.common.exception.ApiErrorCode;
import com.stackup.stackup.common.exception.DomainException;
import com.stackup.stackup.common.storage.ObjectStorageClient;
import com.stackup.stackup.common.storage.StorageException;
import com.stackup.stackup.document.application.dto.AnalyzedDocumentResult;
import com.stackup.stackup.document.domain.AnalyzedDocument;
import com.stackup.stackup.document.domain.AnalyzedDocumentRepository;
import java.net.URI;
import java.time.Duration;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AnalyzedDocumentQueryService {

private static final Logger log = LoggerFactory.getLogger(AnalyzedDocumentQueryService.class);
private static final JsonMapper JSON = JsonMapper.builder().build();
private static final TypeReference<List<String>> TECH_STACK_TYPE = new TypeReference<>() {};
private static final Duration DOWNLOAD_URL_TTL = Duration.ofMinutes(10);

private final AnalyzedDocumentRepository documentRepository;
private final ObjectStorageClient storage;

public List<AnalyzedDocumentResult> listForUser(Long userId, Long resumeId, Long repositoryId) {
List<AnalyzedDocument> rows;
if (resumeId != null) {
rows = documentRepository.findActiveByResumeIdAndOwner(resumeId, userId);
} else if (repositoryId != null) {
rows = documentRepository.findActiveByRepositoryIdAndOwner(repositoryId, userId);
} else {
rows = documentRepository.findActiveByOwner(userId);
}
return rows.stream()
.map(doc -> AnalyzedDocumentResult.of(doc, parseTechStack(doc.getTechStack()), null))
.toList();
}

public AnalyzedDocumentResult getForUser(Long userId, Long documentId) {
AnalyzedDocument doc = documentRepository.findActiveByIdAndOwner(documentId, userId)
.orElseThrow(() -> new DomainException(ApiErrorCode.DOC_NOT_FOUND));
URI downloadUrl = presignedDownloadUrl(doc.getDocumentPath());
return AnalyzedDocumentResult.of(doc, parseTechStack(doc.getTechStack()), downloadUrl);
}

private List<String> parseTechStack(String json) {
if (json == null || json.isBlank()) {
return List.of();
}
try {
return JSON.readValue(json, TECH_STACK_TYPE);
} catch (JsonProcessingException e) {
log.warn("techStack parse failed, return empty. raw={}", json, e);
return List.of();
}
}

private URI presignedDownloadUrl(String key) {
if (key == null || key.isBlank()) {
return null;
}
try {
return storage.createPresignedGetUrl(key, DOWNLOAD_URL_TTL);
} catch (StorageException e) {
log.warn("presigned URL creation failed. key={}", key, e);
return null;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.stackup.stackup.document.application;

import com.stackup.stackup.github.application.event.RepositoryRegisteredEvent;
import lombok.RequiredArgsConstructor;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

// github 도메인 RepositoryRegisteredEvent 수신 → 분석 트리거.
// github 가 document 를 직접 의존하지 않도록 분리.
@Component
@RequiredArgsConstructor
public class RepositoryAnalysisEventListener {

private final AnalysisRequestService analysisRequestService;

@EventListener
public void on(RepositoryRegisteredEvent event) {
analysisRequestService.requestRepositoryAnalysis(event.userId(), event.repositoryId());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.stackup.stackup.document.application;

import com.stackup.stackup.resume.application.event.ResumeUploadedEvent;
import lombok.RequiredArgsConstructor;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

// resume 도메인이 발행한 ResumeUploadedEvent 를 받아 분석 트리거.
// 도메인 간 직접 호출(resume → document)을 피하기 위한 분리. document → resume 단방향만 유지.
@Component
@RequiredArgsConstructor
public class ResumeAnalysisEventListener {

private final AnalysisRequestService analysisRequestService;

@EventListener
public void on(ResumeUploadedEvent event) {
// 동일 트랜잭션 안에서 실행. AnalysisRequestService 내부에서 다시 @TransactionalEventListener(AFTER_COMMIT)
// 로 분기되어 트랜잭션 커밋 후 RabbitMQ 발행.
analysisRequestService.requestResumeAnalysis(event.userId(), event.resumeId());
}
}
Loading