Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.Collectors;

import org.springframework.cache.CacheManager;
Expand Down Expand Up @@ -206,6 +207,10 @@ public CompletableFuture<WebSocketResponseMessage<ReportResponse>> generateRepor
Long userId = getUserIdByAccountId(accountId);
log.info("[WebSocket] accountId {}로 userId {} 조회", accountId, userId);

// 메서드 내부 변수 선언 (스코프 문제 해결)
final AtomicReference<PostInfoResponseMessage> postInfoRef = new AtomicReference<>();
final AtomicReference<SnsPostMetric> postMetricsRef = new AtomicReference<>();

// 1. 캐시 확인
return CompletableFuture.supplyAsync(() -> {
log.info("[WebSocket] 캐시 확인 중 - postId: {}", postId);
Expand All @@ -218,25 +223,31 @@ public CompletableFuture<WebSocketResponseMessage<ReportResponse>> generateRepor
})
.thenCompose(cachedReport -> {
if (cachedReport != null) {
// 캐시된 보고서가 있으면 즉시 완료 (하지만 thenApply는 거침)
return CompletableFuture.completedFuture(cachedReport);
// 캐시된 보고서가 있으면 즉시 완료
return CompletableFuture.supplyAsync(() -> WebSocketResponseMessage.complete(cachedReport, "캐시된 보고서를 찾았습니다!"));
}

// 2. 캐시가 없으면 단계별로 처리
return CompletableFuture.supplyAsync(() -> {
log.info("[WebSocket] 1단계: SNS 서비스에서 post 정보 가져오기 - postId: {}", postId);
return getPostInfo(userId, accountId, postId, storeId);
postInfoRef.set(getPostInfo(userId, accountId, postId, storeId));

// 1단계 완료 시 progress 메시지 반환
return WebSocketResponseMessage.progress(25, "SNS 서비스에서 게시물 정보를 가져왔습니다.");
})
.thenCompose(postInfo ->
.thenCompose(progressMessage1 ->
CompletableFuture.supplyAsync(() -> {
log.info("[WebSocket] 2단계: 게시물 메트릭 조회 - postId: {}", postId);
return getPostMetrics(userId, accountId, postId);
postMetricsRef.set(getPostMetrics(userId, accountId, postId));

// 2단계 완료 시 progress 메시지 반환
return WebSocketResponseMessage.progress(50, "게시물 메트릭을 조회했습니다.");
})
.thenCompose(postMetrics ->
.thenCompose(progressMessage2 ->
CompletableFuture.supplyAsync(() -> {
log.info("[WebSocket] 3단계: AI 보고서 생성 - postId: {}", postId);
ReportResponse reportResponse = generateAiReport(userId, accountId, postId, storeId, postInfo, postMetrics);
log.info("[WebSocket] 3단계: AI 보고서 생성 - postId: {}", postId);
ReportResponse reportResponse = generateAiReport(userId, accountId, postId, storeId, postInfoRef.get(), postMetricsRef.get());

// 새로 생성된 보고서를 캐시에 저장 (cachedReport가 null일 때만)
try {
String cacheKey = postId + "_" + userId + "_" + accountId + "_" + storeId;
Expand All @@ -246,17 +257,11 @@ public CompletableFuture<WebSocketResponseMessage<ReportResponse>> generateRepor
log.warn("[WebSocket] 캐시 저장 중 에러 발생 - postId: {}, error: {}", postId, e.getMessage());
}

return reportResponse;
// 3단계 완료 시 바로 complete 메시지 반환
return WebSocketResponseMessage.complete(reportResponse, "AI 분석 보고서가 완성되었습니다!");
})
)
);
})
.thenApply(reportResponse -> {
log.info("[WebSocket] 최종 결과 반환 - data: {}", reportResponse);

// 최종 결과를 WebSocketResponseMessage로 변환
return WebSocketResponseMessage.complete(reportResponse, "AI 분석 보고서가 완성되었습니다!");
});
}

// ===== PRIVATE METHODS =====
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@
import kt.aivle.sns.adapter.in.web.dto.OAuthContext;
import kt.aivle.sns.application.service.AccountSyncDelegator;
import kt.aivle.sns.application.service.SnsOAuthDelegator;
import kt.aivle.sns.application.service.oauth.OAuthTemplateService;
import kt.aivle.sns.domain.model.SnsType;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

Expand All @@ -20,6 +23,7 @@ public class SnsOAuthController {
private final SnsOAuthDelegator delegator;
private final AccountSyncDelegator syncDelegator;
private final ResponseUtils responseUtils;
private final OAuthTemplateService templateService;

// 각 SNS 연동 버튼 누르면 호출
@GetMapping("/{snsType}/login")
Expand All @@ -31,13 +35,29 @@ public ResponseEntity<ApiResponse<String>> getAuthUrl(@PathVariable SnsType snsT
}

@GetMapping("/{snsType}/callback")
public ResponseEntity<ApiResponse<String>> callback(@PathVariable SnsType snsType,
public ResponseEntity<String> callback(@PathVariable SnsType snsType,
@RequestParam String code,
@RequestParam String state) {
OAuthContext ctx = delegator.handleCallback(snsType, state, code);
// account 초기화
syncDelegator.accountSync(snsType, ctx.userId(), ctx.storeId());

return responseUtils.build(OK, "계정 연동이 완료되었습니다.");
try {
OAuthContext ctx = delegator.handleCallback(snsType, state, code);
// account 초기화
syncDelegator.accountSync(snsType, ctx.userId(), ctx.storeId());

log.info("SNS OAuth 연동 성공: snsType={}, userId={}, storeId={}",
snsType, ctx.userId(), ctx.storeId());

String successHtml = templateService.createSuccessHtml(snsType);
return ResponseEntity.ok()
.contentType(MediaType.TEXT_HTML)
.body(successHtml);

} catch (Exception e) {
log.error("SNS OAuth 연동 실패: snsType={}, error={}", snsType, e.getMessage());

String errorHtml = templateService.createErrorHtml(snsType, e.getMessage());
return ResponseEntity.ok()
.contentType(MediaType.TEXT_HTML)
.body(errorHtml);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package kt.aivle.sns.application.service.oauth;

import kt.aivle.sns.domain.model.SnsType;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.nio.charset.StandardCharsets;

@Slf4j
@Service
public class OAuthTemplateService {

private static final String SUCCESS_TEMPLATE_PATH = "templates/oauth-success.html";
private static final String ERROR_TEMPLATE_PATH = "templates/oauth-error.html";

public String createSuccessHtml(SnsType snsType) {
try {
String template = loadTemplate(SUCCESS_TEMPLATE_PATH);
String snsName = getSnsDisplayName(snsType);

return template
.replace("{snsName}", snsName);

} catch (Exception e) {
log.error("Failed to create success HTML template", e);
return createFallbackSuccessHtml(snsType);
}
}

public String createErrorHtml(SnsType snsType, String errorMessage) {
try {
String template = loadTemplate(ERROR_TEMPLATE_PATH);
String snsName = getSnsDisplayName(snsType);

return template
.replace("{snsName}", snsName)
.replace("{errorMessage}", errorMessage != null ? errorMessage : "알 수 없는 오류");

} catch (Exception e) {
log.error("Failed to create error HTML template", e);
return createFallbackErrorHtml(snsType, errorMessage);
}
}

private String loadTemplate(String templatePath) throws IOException {
ClassPathResource resource = new ClassPathResource(templatePath);
return resource.getContentAsString(StandardCharsets.UTF_8);
}

private String getSnsDisplayName(SnsType snsType) {
return switch (snsType) {
case youtube -> "YouTube";
// case instagram -> "Instagram";
// case facebook -> "Facebook";
default -> snsType.name().toUpperCase();
};
}

private String createFallbackSuccessHtml(SnsType snsType) {
String snsName = getSnsDisplayName(snsType);
return """
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>계정 연동 완료</title>
</head>
<body>
<h2>✅ %s 계정 연동이 완료되었습니다!</h2>
<p>이 창을 닫고 원래 페이지로 돌아가세요.</p>
<script>
setTimeout(() => window.close(), 3000);
</script>
</body>
</html>
""".formatted(snsName);
}

private String createFallbackErrorHtml(SnsType snsType, String errorMessage) {
String snsName = getSnsDisplayName(snsType);
return """
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>계정 연동 실패</title>
</head>
<body>
<h2>❌ %s 계정 연동에 실패했습니다</h2>
<p>오류: %s</p>
<button onclick="window.close()">창 닫기</button>
</body>
</html>
""".formatted(snsName, errorMessage != null ? errorMessage : "알 수 없는 오류");
}
}
97 changes: 97 additions & 0 deletions sns-service/src/main/resources/templates/oauth-error.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>계정 연동 실패</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
sans-serif;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background: #f7f7fe;
color: #1e293b;
}
.container {
background: white;
padding: 2.5rem;
border-radius: 16px;
border: 1px solid #e2e8f0;
text-align: center;
max-width: 380px;
width: 90%;
}
.error-icon {
width: 48px;
height: 48px;
background: #ef4444;
border-radius: 50%;
margin: 0 auto 1.5rem;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
color: white;
}
h1 {
color: #0f172a;
margin-bottom: 0.5rem;
font-size: 1.25rem;
font-weight: 600;
}
p {
color: #64748b;
margin-bottom: 1rem;
line-height: 1.5;
font-size: 0.95rem;
}
.error-detail {
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 8px;
padding: 1rem;
margin: 1rem 0 2rem;
font-size: 0.875rem;
color: #dc2626;
text-align: center;
line-height: 1.4;
}
.close-btn {
background: #0f172a;
color: white;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.close-btn:hover {
background: #334155;
transform: translateY(-1px);
}
</style>
</head>
<body>
<div class="container">
<div class="error-icon">×</div>
<h1>연동 실패</h1>
<p>계정 연결 중 오류가 발생했습니다.</p>
<div class="error-detail">
인증 코드가 유효하지 않습니다. 다시 시도해 주세요.
</div>
<button class="close-btn" onclick="closeWindow()">확인</button>
</div>

<script>
function closeWindow() {
window.close();
}
</script>
</body>
</html>
Loading
Loading