diff --git a/domain/generator/src/main/kotlin/com/few/generator/controller/SchedulingController.kt b/domain/generator/src/main/kotlin/com/few/generator/controller/SchedulingController.kt index ba7ca9039..b635124d4 100644 --- a/domain/generator/src/main/kotlin/com/few/generator/controller/SchedulingController.kt +++ b/domain/generator/src/main/kotlin/com/few/generator/controller/SchedulingController.kt @@ -2,6 +2,7 @@ package com.few.generator.controller import com.few.generator.usecase.GlobalGenSchedulingUseCase import com.few.generator.usecase.LocalGenSchedulingUseCase +import com.few.generator.usecase.NasdaqDailyStockCardSchedulingUseCase import com.few.generator.usecase.RefreshInstagramTokenUseCase import com.few.generator.usecase.SendCacheMetricsSchedulingUseCase import com.few.generator.usecase.SendNewsletterSchedulingUseCase @@ -16,6 +17,7 @@ class SchedulingController( private val sendCacheMetricsSchedulingUseCase: SendCacheMetricsSchedulingUseCase, private val sendNewsletterSchedulingUseCase: SendNewsletterSchedulingUseCase, private val refreshInstagramTokenUseCase: RefreshInstagramTokenUseCase, + private val nasdaqDailyStockCardSchedulingUseCase: NasdaqDailyStockCardSchedulingUseCase, ) { private val log = KotlinLogging.logger {} @@ -47,4 +49,9 @@ class SchedulingController( log.error(e) { "Instagram 토큰 갱신 스케줄 실행 중 오류 발생: ${e.message}" } } } + + @Scheduled(cron = "\${scheduling.cron.nasdaq-daily-stock}", zone = "Asia/Seoul") + fun nasdaqDailyStockScheduling() { + nasdaqDailyStockCardSchedulingUseCase.execute() + } } \ No newline at end of file diff --git a/domain/generator/src/main/kotlin/com/few/generator/core/instagram/InstagramUploader.kt b/domain/generator/src/main/kotlin/com/few/generator/core/instagram/InstagramUploader.kt index 906d1b03e..a7146458e 100644 --- a/domain/generator/src/main/kotlin/com/few/generator/core/instagram/InstagramUploader.kt +++ b/domain/generator/src/main/kotlin/com/few/generator/core/instagram/InstagramUploader.kt @@ -75,6 +75,42 @@ class InstagramUploader( } } + // 단일 이미지 컨테이너 생성 (캐러셀 아님) + fun createSingleMediaContainer( + imageUrl: String, + caption: String, + ): String? { + val url = + "https://graph.instagram.com/$accountId/media" + .toHttpUrlOrNull() + ?.newBuilder() + ?.addQueryParameter("access_token", instagramTokenService.getLatestAccessToken()) + ?.addQueryParameter("image_url", imageUrl) + ?.addQueryParameter("caption", caption) + ?.build() + + val request = + Request + .Builder() + .url(url!!) + .post(RequestBody.create(null, "")) + .build() + + instagramOkHttpClient.newCall(request).execute().use { response -> + val responseBody = response.body?.string() + if (!response.isSuccessful) { + val errorResponse = parseErrorResponse(responseBody) + logErrorResponse("[SingleMedia] MediaContainer", response.code, errorResponse) + throw RuntimeException( + "[Instagram][SingleMedia] Creation of MediaContainer Failed: ${errorResponse?.error?.message ?: "Unknown error"}", + ) + } + DelayUtil.randomDelay(5, 10) + + return responseBody?.let { parseJsonForId(it).id } + } + } + // 2단계: 캐러셀용 부모 컨테이너 생성 fun createParentMediaContainer( imageUrls: List, diff --git a/domain/generator/src/main/kotlin/com/few/generator/core/instagram/NasdaqDailyStockCardGenerator.kt b/domain/generator/src/main/kotlin/com/few/generator/core/instagram/NasdaqDailyStockCardGenerator.kt new file mode 100644 index 000000000..c8421a3ff --- /dev/null +++ b/domain/generator/src/main/kotlin/com/few/generator/core/instagram/NasdaqDailyStockCardGenerator.kt @@ -0,0 +1,348 @@ +package com.few.generator.core.instagram + +import com.few.generator.core.instagram.CardImageGeneratorUtils.drawText +import com.few.generator.core.instagram.CardImageGeneratorUtils.loadImageResource +import com.few.generator.core.instagram.CardImageGeneratorUtils.loadKoreanFont +import com.few.generator.core.instagram.CardImageGeneratorUtils.saveImage +import com.few.generator.core.instagram.CardImageGeneratorUtils.setupGraphics +import com.few.generator.core.kis.OverseaStockConstants +import com.few.generator.core.kis.StockQuote +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Component +import java.awt.AlphaComposite +import java.awt.BasicStroke +import java.awt.Color +import java.awt.Graphics2D +import java.awt.image.BufferedImage +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.util.Locale + +/** + * 나스닥 주요 종목 주식 카드 이미지 생성기 (1080 × 1080 – 1:1 인스타그램) + * + * 레이아웃: + * Y= 0 ~ 120 : 헤더 (딥네이비 #1F2333) – "NASDAQ DAILY" + 날짜 + * Y= 120 ~ 320 : ETF 라운드 카드 3개 수평 배치 (상단 패딩 30 + 카드 160 + 하단 간격 10) + * Y= 320 ~ 375 : M7 섹션 타이틀 (아쿠아 + 밑줄) + * Y= 375 ~ 970 : M7 종목 7행 × 85px (로고 | 이름 | 가격 | 등락률), 세로 중앙 정렬 + * Y= 975 ~ 1080 : 푸터 – Market Mood (선택) + few_logo.png + */ +@Component +class NasdaqDailyStockCardGenerator { + private val log = KotlinLogging.logger {} + + companion object { + private const val IMAGE_WIDTH = 1080 + private const val IMAGE_HEIGHT = 1080 + + // ── Header ─────────────────────────────────────────────────────────── + private const val HEADER_HEIGHT = 120 + private const val HEADER_TITLE_X = 100 + + // ── ETF ────────────────────────────────────────────────────────────── + private const val ETF_SECTION_Y = HEADER_HEIGHT // 120 + private const val ETF_CARDS_Y = ETF_SECTION_Y + 30 // 150 + private const val ETF_CARD_HEIGHT = 160 + private const val ETF_CARD_CORNER = 20 + private const val ETF_SIDE_MARGIN = 30 + private const val ETF_CARD_GAP = 12 + + // ── M7 ─────────────────────────────────────────────────────────────── + private const val M7_SECTION_Y = ETF_CARDS_Y + ETF_CARD_HEIGHT + 10 // 320 + private const val M7_TITLE_AREA_H = 55 + private const val M7_TITLE_X = 100 + private const val M7_ROW_HEIGHT = 85 + + // M7 row column positions + private const val COMPANY_LOGO_SIZE = 44 + private const val M7_COL_LOGO = 100 + private const val M7_COL_NAME = M7_COL_LOGO + COMPANY_LOGO_SIZE + 20 // 114 + private const val M7_COL_PRICE = 610 + private const val M7_COL_CHANGE = 850 + + // ── Footer ──────────────────────────────────────────────────────────── + private const val FOOTER_START_Y = 975 + private const val FEW_LOGO_MAX_SIZE = 40 + + // ── Font sizes ──────────────────────────────────────────────────────── + private const val NAME_FONT_SIZE = 22 + private const val PRICE_FONT_SIZE = 20 + private const val CHANGE_FONT_SIZE = 20 + + // ── Colors ──────────────────────────────────────────────────────────── + private val HEADER_BG_COLOR = Color(0x1F, 0x23, 0x33) + private val AQUA_BLUE = Color(0x63, 0xC7, 0xE6) + private val BG_COLOR = Color(0xF4, 0xF6, 0xF8) + private val RISE_COLOR = Color(0xE0, 0x57, 0x57) + private val FALL_COLOR = Color(0x2C, 0x4A, 0x6E) + private val NEUTRAL_COLOR = Color(0x99, 0x99, 0x99) + private val TEXT_COLOR = Color(0x33, 0x33, 0x33) + private val DIVIDER_COLOR = Color(0xE0, 0xE4, 0xE8) + + private val DATE_FORMATTER = DateTimeFormatter.ofPattern("M월 d일 | E요일", Locale.KOREAN) + + private val BRAND_COLORS: Map = + mapOf( + "AAPL" to Color(100, 100, 100), + "MSFT" to Color(0, 164, 239), + "GOOGL" to Color(66, 133, 244), + "AMZN" to Color(255, 153, 0), + "NVDA" to Color(118, 185, 0), + "META" to Color(8, 102, 255), + "TSLA" to Color(204, 0, 0), + ) + } + + fun generateImage( + stocks: Map>, + outputPath: String, + date: LocalDate = LocalDate.now(), + marketMood: String = "", + ): Boolean { + log.debug { "나스닥 주식 카드 이미지 생성 시작 (종목 수: ${stocks.values.sumOf { it.size }})" } + + val image = BufferedImage(IMAGE_WIDTH, IMAGE_HEIGHT, BufferedImage.TYPE_INT_ARGB) + val graphics = image.createGraphics() + setupGraphics(graphics) + + try { + graphics.color = BG_COLOR + graphics.fillRect(0, 0, IMAGE_WIDTH, IMAGE_HEIGHT) + + drawHeader(graphics, date) + + val etfStocks = stocks[OverseaStockConstants.StockGroup.ETF] ?: emptyList() + val m7Stocks = stocks[OverseaStockConstants.StockGroup.M7] ?: emptyList() + + drawEtfCards(graphics, etfStocks) + drawM7Section(graphics, m7Stocks) + drawFooter(graphics, marketMood) + + return saveImage(image, outputPath) + } finally { + graphics.dispose() + } + } + + private fun drawHeader( + graphics: Graphics2D, + date: LocalDate, + ) { + graphics.color = HEADER_BG_COLOR + graphics.fillRect(0, 0, IMAGE_WIDTH, HEADER_HEIGHT) + + val labelFont = loadKoreanFont(32, bold = true) + val dateFont = loadKoreanFont(20, bold = false) + + drawText(graphics, "일간 미국 지수", HEADER_TITLE_X, 55, labelFont, AQUA_BLUE) + + val orig = graphics.composite + graphics.composite = AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.8f) + drawText(graphics, date.format(DATE_FORMATTER), HEADER_TITLE_X, 95, dateFont, Color.WHITE) + graphics.composite = orig + } + + /** + * ETF 종목을 라운드 흰색 카드 3개로 수평 배치. + * 각 카드: 인덱스명 (위) + 등락률 (아래), 모두 중앙 정렬. + */ + private fun drawEtfCards( + graphics: Graphics2D, + stocks: List, + ) { + if (stocks.isEmpty()) return + + val n = stocks.size + val totalCardWidth = IMAGE_WIDTH - ETF_SIDE_MARGIN * 2 - ETF_CARD_GAP * (n - 1) + val cardWidth = totalCardWidth / n + + val nameFont = loadKoreanFont(32, bold = true) + val rateFont = loadKoreanFont(24, bold = true) + + // 두 텍스트 블록을 카드 높이 기준으로 세로 중앙 정렬 + graphics.font = nameFont + val nameMetrics = graphics.fontMetrics + graphics.font = rateFont + val rateMetrics = graphics.fontMetrics + val textGap = 10 + val blockH = nameMetrics.height + textGap + rateMetrics.height + val blockStartY = ETF_CARDS_Y + (ETF_CARD_HEIGHT - blockH) / 2 + val nameBaselineY = blockStartY + nameMetrics.ascent + val rateBaselineY = blockStartY + nameMetrics.height + textGap + rateMetrics.ascent + + stocks.forEachIndexed { i, stock -> + val cardX = ETF_SIDE_MARGIN + i * (cardWidth + ETF_CARD_GAP) + + // Subtle drop shadow + val origComposite = graphics.composite + graphics.composite = AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.08f) + graphics.color = Color.BLACK + graphics.fillRoundRect(cardX + 2, ETF_CARDS_Y + 4, cardWidth, ETF_CARD_HEIGHT, ETF_CARD_CORNER, ETF_CARD_CORNER) + graphics.composite = origComposite + + // Card background + graphics.color = Color.WHITE + graphics.fillRoundRect(cardX, ETF_CARDS_Y, cardWidth, ETF_CARD_HEIGHT, ETF_CARD_CORNER, ETF_CARD_CORNER) + + // Index name – horizontally centered, vertically centered + graphics.font = nameFont + val nameW = graphics.fontMetrics.stringWidth(stock.koreanName) + val nameX = cardX + (cardWidth - nameW) / 2 + drawText(graphics, stock.koreanName, nameX, nameBaselineY, nameFont, TEXT_COLOR) + + // Change rate – horizontally centered, vertically centered + val (arrow, changeColor) = changeArrow(stock) + val rateText = "$arrow ${stock.changeRate}%" + graphics.font = rateFont + val rateW = graphics.fontMetrics.stringWidth(rateText) + val rateX = cardX + (cardWidth - rateW) / 2 + drawText(graphics, rateText, rateX, rateBaselineY, rateFont, changeColor) + } + } + + /** + * M7 섹션: "M7" 타이틀 + 아쿠아 밑줄 + 7개 종목 행. + * 각 행: 한국어 종목명 | 현재가 | 등락률 | 우측 로고. + */ + private fun drawM7Section( + graphics: Graphics2D, + stocks: List, + ) { + // Title + val titleFont = loadKoreanFont(28, bold = true) + val titleTextY = M7_SECTION_Y + 40 + drawText(graphics, "M7", M7_TITLE_X, titleTextY, titleFont, AQUA_BLUE) + + // Underline + val titleW = CardImageGeneratorUtils.getTextWidth(graphics, "M7", titleFont) + graphics.color = AQUA_BLUE + graphics.stroke = BasicStroke(2f) + graphics.drawLine(M7_TITLE_X, titleTextY + 6, M7_TITLE_X + titleW, titleTextY + 6) + + // Center rows vertically in available space + val totalRowsH = stocks.size * M7_ROW_HEIGHT + val available = FOOTER_START_Y - M7_SECTION_Y - M7_TITLE_AREA_H + val rowsStartY = M7_SECTION_Y + M7_TITLE_AREA_H + (available - totalRowsH).coerceAtLeast(0) / 2 + + val nameFont = loadKoreanFont(NAME_FONT_SIZE, bold = true) + val priceFont = loadKoreanFont(PRICE_FONT_SIZE, bold = false) + val changeFont = loadKoreanFont(CHANGE_FONT_SIZE, bold = true) + + stocks.forEachIndexed { idx, stock -> + val rowY = rowsStartY + idx * M7_ROW_HEIGHT + val textY = rowY + (M7_ROW_HEIGHT + PRICE_FONT_SIZE) / 2 + val logoY = rowY + (M7_ROW_HEIGHT - COMPANY_LOGO_SIZE) / 2 + + val (arrow, changeColor) = changeArrow(stock) + + // Korean company name + drawText(graphics, stock.koreanName, M7_COL_NAME, textY, nameFont, TEXT_COLOR) + + // Current price + drawText(graphics, "$${stock.currentPrice}", M7_COL_PRICE, textY, priceFont, TEXT_COLOR) + + // Change rate with direction arrow + drawText(graphics, "$arrow ${stock.changeRate}%", M7_COL_CHANGE, textY, changeFont, changeColor) + + // Company logo (far right) + drawCompanyLogo(graphics, stock.symbol, M7_COL_LOGO, logoY, COMPANY_LOGO_SIZE) + + // Row divider (skip last row) + if (idx < stocks.size - 1) { + graphics.color = DIVIDER_COLOR + graphics.stroke = BasicStroke(1f) + graphics.drawLine(40, rowY + M7_ROW_HEIGHT, IMAGE_WIDTH - 40, rowY + M7_ROW_HEIGHT) + } + } + } + + private fun drawFooter( + graphics: Graphics2D, + marketMood: String, + ) { + // Thin separator line + graphics.color = DIVIDER_COLOR + graphics.stroke = BasicStroke(1f) + graphics.drawLine(40, FOOTER_START_Y + 10, IMAGE_WIDTH - 40, FOOTER_START_Y + 10) + + // Market Mood (optional) + if (marketMood.isNotBlank()) { + val moodFont = loadKoreanFont(15, bold = false) + val moodText = "Market Mood | $marketMood" + graphics.font = moodFont + val moodW = graphics.fontMetrics.stringWidth(moodText) + val moodX = (IMAGE_WIDTH - moodW) / 2 + drawText(graphics, moodText, moodX, FOOTER_START_Y + 40, moodFont, Color(0x88, 0x88, 0x88)) + } + + // few_logo.png centered + val logoImage = loadImageResource("few_logo.png") + if (logoImage != null) { + val aspectRatio = logoImage.width.toDouble() / logoImage.height.toDouble() + val (newWidth, newHeight) = + if (logoImage.width > logoImage.height) { + Pair(FEW_LOGO_MAX_SIZE, (FEW_LOGO_MAX_SIZE / aspectRatio).toInt()) + } else { + Pair((FEW_LOGO_MAX_SIZE * aspectRatio).toInt(), FEW_LOGO_MAX_SIZE) + } + val resizedLogo = CardImageGeneratorUtils.resizeImage(logoImage, newWidth, newHeight) + val logoX = (IMAGE_WIDTH - newWidth) / 2 + val logoY = if (marketMood.isNotBlank()) FOOTER_START_Y + 58 else FOOTER_START_Y + 35 + graphics.drawImage(resizedLogo, logoX, logoY, null) + } + } + + private fun drawCompanyLogo( + graphics: Graphics2D, + symbol: String, + x: Int, + y: Int, + size: Int, + ) { + val logoImage = loadImageResource("m7/${symbol.lowercase()}_logo.png") + if (logoImage != null) { + val aspectRatio = logoImage.width.toDouble() / logoImage.height.toDouble() + val (newWidth, newHeight) = + if (aspectRatio >= 1.0) { + Pair(size, (size / aspectRatio).toInt()) + } else { + Pair((size * aspectRatio).toInt(), size) + } + val resized = CardImageGeneratorUtils.resizeImage(logoImage, newWidth, newHeight) + val offsetX = (size - newWidth) / 2 + val offsetY = (size - newHeight) / 2 + graphics.drawImage(resized, x + offsetX, y + offsetY, null) + } else { + drawCompanyBadge(graphics, symbol, x, y, size) + } + } + + private fun drawCompanyBadge( + graphics: Graphics2D, + symbol: String, + x: Int, + y: Int, + size: Int, + ) { + val brandColor = BRAND_COLORS[symbol] ?: AQUA_BLUE + graphics.color = brandColor + graphics.fillOval(x, y, size, size) + + val font = loadKoreanFont((size * 0.42).toInt(), bold = true) + val letter = symbol.first().toString() + graphics.font = font + val metrics = graphics.fontMetrics + val letterX = x + (size - metrics.stringWidth(letter)) / 2 + val letterY = y + (size + metrics.ascent - metrics.descent) / 2 + graphics.color = Color.WHITE + graphics.drawString(letter, letterX, letterY) + } + + private fun changeArrow(stock: StockQuote): Pair = + when (stock.isRise) { + true -> "▲" to RISE_COLOR + false -> "▼" to FALL_COLOR + null -> "-" to NEUTRAL_COLOR + } +} \ No newline at end of file diff --git a/domain/generator/src/main/kotlin/com/few/generator/core/kis/KisClient.kt b/domain/generator/src/main/kotlin/com/few/generator/core/kis/KisClient.kt new file mode 100644 index 000000000..2aabd7c88 --- /dev/null +++ b/domain/generator/src/main/kotlin/com/few/generator/core/kis/KisClient.kt @@ -0,0 +1,20 @@ +package com.few.generator.core.kis + +import com.few.generator.core.kis.dto.KisStockPriceResponse +import feign.FeignException +import org.springframework.cloud.openfeign.FeignClient +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestParam + +@FeignClient(value = "kis") +interface KisClient { + @GetMapping("/uapi/overseas-price/v1/quotations/price-detail") + @Throws(FeignException::class) + fun getStockPrice( + @RequestHeader("authorization") authorization: String, + @RequestHeader("tr_id") trId: String, + @RequestParam("EXCD") excd: String, + @RequestParam("SYMB") symb: String, + ): KisStockPriceResponse +} \ No newline at end of file diff --git a/domain/generator/src/main/kotlin/com/few/generator/core/kis/KisStockFetcher.kt b/domain/generator/src/main/kotlin/com/few/generator/core/kis/KisStockFetcher.kt new file mode 100644 index 000000000..74051cfde --- /dev/null +++ b/domain/generator/src/main/kotlin/com/few/generator/core/kis/KisStockFetcher.kt @@ -0,0 +1,64 @@ +package com.few.generator.core.kis + +import com.few.generator.core.kis.dto.KisTokenRequest +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component + +@Component +class KisStockFetcher( + private val kisTokenClient: KisTokenClient, + private val kisClient: KisClient, + @Value("\${KIS_APP_KEY:thisis-kis-app-key}") + private val appKey: String, + @Value("\${KIS_APP_SECRET:thisis-kis-app-secret}") + private val appSecret: String, +) { + private val log = KotlinLogging.logger {} + + fun fetchAll(): Map> { + val accessToken = issueToken() + val authorization = "Bearer $accessToken" + + return OverseaStockConstants.DAILY_NASDAQ_STOCK_GROUP_MAP.mapValues { (_, stocks) -> + stocks.mapNotNull { stock -> + runCatching { + val response = + kisClient.getStockPrice( + authorization = authorization, + trId = OverseaStockConstants.OVERSEA_PRICE_DETAIL_TR_ID, + excd = stock.excd, + symb = stock.symbol, + ) + + if (!response.isSuccess() || response.output == null) { + log.warn { "[${stock.symbol}] KIS API 응답 실패: rt_cd=${response.rtCd}, msg=${response.msg1}" } + return@runCatching null + } + + val output = response.output + + StockQuote( + symbol = stock.symbol, + koreanName = stock.koreanName, + currentPrice = output.last, + changeRate = output.t_xrat, + ) + }.onFailure { e -> + log.error(e) { "[${stock.symbol}] KIS 주식 데이터 조회 실패: ${e.message}" } + }.getOrNull() + } + } + } + + private fun issueToken(): String { + val response = + kisTokenClient.getToken( + KisTokenRequest( + appKey = appKey, + appSecret = appSecret, + ), + ) + return response.accessToken + } +} \ No newline at end of file diff --git a/domain/generator/src/main/kotlin/com/few/generator/core/kis/KisTokenClient.kt b/domain/generator/src/main/kotlin/com/few/generator/core/kis/KisTokenClient.kt new file mode 100644 index 000000000..88c2c71b4 --- /dev/null +++ b/domain/generator/src/main/kotlin/com/few/generator/core/kis/KisTokenClient.kt @@ -0,0 +1,17 @@ +package com.few.generator.core.kis + +import com.few.generator.core.kis.dto.KisTokenRequest +import com.few.generator.core.kis.dto.KisTokenResponse +import feign.FeignException +import org.springframework.cloud.openfeign.FeignClient +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody + +@FeignClient(value = "kis-token") +interface KisTokenClient { + @PostMapping("/oauth2/tokenP") + @Throws(FeignException::class) + fun getToken( + @RequestBody request: KisTokenRequest, + ): KisTokenResponse +} \ No newline at end of file diff --git a/domain/generator/src/main/kotlin/com/few/generator/core/kis/OverseaStockConstants.kt b/domain/generator/src/main/kotlin/com/few/generator/core/kis/OverseaStockConstants.kt new file mode 100644 index 000000000..60ef3f388 --- /dev/null +++ b/domain/generator/src/main/kotlin/com/few/generator/core/kis/OverseaStockConstants.kt @@ -0,0 +1,31 @@ +package com.few.generator.core.kis + +object OverseaStockConstants { + const val OVERSEA_PRICE_DETAIL_TR_ID = "HHDFS76200200" + + /** KIS 거래소 코드 */ + const val EXCD_NAS = "NAS" // 나스닥 + const val EXCD_AMS = "AMS" // 아멕스 + + enum class StockGroup { M7, ETF } + + /** M7 개별 종목 */ + val AAPL = Stock("AAPL", "애플", EXCD_NAS) + val MSFT = Stock("MSFT", "마이크로소프트", EXCD_NAS) + val GOOGL = Stock("GOOGL", "알파벳", EXCD_NAS) + val AMZN = Stock("AMZN", "아마존", EXCD_NAS) + val NVDA = Stock("NVDA", "엔비디아", EXCD_NAS) + val META = Stock("META", "메타", EXCD_NAS) + val TSLA = Stock("TSLA", "테슬라", EXCD_NAS) + + /** ETF 개별 종목 */ + val SPY = Stock("SPY", "S&P500", EXCD_AMS) + val QQQ = Stock("QQQ", "나스닥100", EXCD_NAS) + val SCHD = Stock("SCHD", "다우존스", EXCD_AMS) + + val DAILY_NASDAQ_STOCK_GROUP_MAP: Map> = + mapOf( + StockGroup.ETF to listOf(SPY, QQQ, SCHD), + StockGroup.M7 to listOf(AAPL, MSFT, GOOGL, AMZN, NVDA, META, TSLA), + ) +} \ No newline at end of file diff --git a/domain/generator/src/main/kotlin/com/few/generator/core/kis/Stock.kt b/domain/generator/src/main/kotlin/com/few/generator/core/kis/Stock.kt new file mode 100644 index 000000000..37e37be95 --- /dev/null +++ b/domain/generator/src/main/kotlin/com/few/generator/core/kis/Stock.kt @@ -0,0 +1,7 @@ +package com.few.generator.core.kis + +data class Stock( + val symbol: String, // 주식 티커 + val koreanName: String, // 회사 이름 + val excd: String, // 거래소 +) \ No newline at end of file diff --git a/domain/generator/src/main/kotlin/com/few/generator/core/kis/StockQuote.kt b/domain/generator/src/main/kotlin/com/few/generator/core/kis/StockQuote.kt new file mode 100644 index 000000000..fa972b04d --- /dev/null +++ b/domain/generator/src/main/kotlin/com/few/generator/core/kis/StockQuote.kt @@ -0,0 +1,19 @@ +package com.few.generator.core.kis + +data class StockQuote( + val symbol: String, + val koreanName: String, + /** 현재가 (USD) */ + val currentPrice: String, + /** 등락률 (%) */ + val changeRate: String, +) { + /** 상승 여부 (true: 상승, false: 하락, null: 보합) */ + val isRise: Boolean? + get() = + when { + changeRate.startsWith("+") -> true + changeRate.startsWith("-") -> false + else -> null + } +} \ No newline at end of file diff --git a/domain/generator/src/main/kotlin/com/few/generator/core/kis/dto/KisStockPriceResponse.kt b/domain/generator/src/main/kotlin/com/few/generator/core/kis/dto/KisStockPriceResponse.kt new file mode 100644 index 000000000..34fe7de5f --- /dev/null +++ b/domain/generator/src/main/kotlin/com/few/generator/core/kis/dto/KisStockPriceResponse.kt @@ -0,0 +1,20 @@ +package com.few.generator.core.kis.dto + +import com.google.gson.annotations.SerializedName + +data class KisStockPriceResponse( + @SerializedName("rt_cd") + val rtCd: String, + @SerializedName("msg1") + val msg1: String, + val output: Output?, +) { + data class Output( + /** 현재가 */ + val last: String, + /** 원환산 등락률 (%) */ + val t_xrat: String, + ) + + fun isSuccess(): Boolean = rtCd == "0" +} \ No newline at end of file diff --git a/domain/generator/src/main/kotlin/com/few/generator/core/kis/dto/KisTokenRequest.kt b/domain/generator/src/main/kotlin/com/few/generator/core/kis/dto/KisTokenRequest.kt new file mode 100644 index 000000000..3a3aadf4f --- /dev/null +++ b/domain/generator/src/main/kotlin/com/few/generator/core/kis/dto/KisTokenRequest.kt @@ -0,0 +1,12 @@ +package com.few.generator.core.kis.dto + +import com.google.gson.annotations.SerializedName + +data class KisTokenRequest( + @SerializedName("grant_type") + val grantType: String = "client_credentials", + @SerializedName("appkey") + val appKey: String, + @SerializedName("appsecret") + val appSecret: String, +) \ No newline at end of file diff --git a/domain/generator/src/main/kotlin/com/few/generator/core/kis/dto/KisTokenResponse.kt b/domain/generator/src/main/kotlin/com/few/generator/core/kis/dto/KisTokenResponse.kt new file mode 100644 index 000000000..468ca79f8 --- /dev/null +++ b/domain/generator/src/main/kotlin/com/few/generator/core/kis/dto/KisTokenResponse.kt @@ -0,0 +1,12 @@ +package com.few.generator.core.kis.dto + +import com.google.gson.annotations.SerializedName + +data class KisTokenResponse( + @SerializedName("access_token") + val accessToken: String, + @SerializedName("token_type") + val tokenType: String, + @SerializedName("expires_in") + val expiresIn: Long, +) \ No newline at end of file diff --git a/domain/generator/src/main/kotlin/com/few/generator/support/common/NyseMarketCalendar.kt b/domain/generator/src/main/kotlin/com/few/generator/support/common/NyseMarketCalendar.kt new file mode 100644 index 000000000..e58a12f8e --- /dev/null +++ b/domain/generator/src/main/kotlin/com/few/generator/support/common/NyseMarketCalendar.kt @@ -0,0 +1,43 @@ +package com.few.generator.support.common + +import org.springframework.stereotype.Component +import java.time.DayOfWeek +import java.time.LocalDate + +@Component +class NyseMarketCalendar { + fun isTradingDay(date: LocalDate): Boolean { + if (date.dayOfWeek == DayOfWeek.SATURDAY || date.dayOfWeek == DayOfWeek.SUNDAY) { + return false + } + return date !in NYSE_HOLIDAYS + } + + companion object { + private val NYSE_HOLIDAYS = + setOf( + // 2025 + LocalDate.of(2025, 1, 1), // New Year's Day + LocalDate.of(2025, 1, 20), // MLK Jr. Day + LocalDate.of(2025, 2, 17), // Presidents' Day + LocalDate.of(2025, 4, 18), // Good Friday + LocalDate.of(2025, 5, 26), // Memorial Day + LocalDate.of(2025, 6, 19), // Juneteenth + LocalDate.of(2025, 7, 4), // Independence Day + LocalDate.of(2025, 9, 1), // Labor Day + LocalDate.of(2025, 11, 27), // Thanksgiving Day + LocalDate.of(2025, 12, 25), // Christmas Day + // 2026 + LocalDate.of(2026, 1, 1), // New Year's Day + LocalDate.of(2026, 1, 19), // MLK Jr. Day + LocalDate.of(2026, 2, 16), // Presidents' Day + LocalDate.of(2026, 4, 3), // Good Friday + LocalDate.of(2026, 5, 25), // Memorial Day + LocalDate.of(2026, 6, 19), // Juneteenth + LocalDate.of(2026, 7, 3), // Independence Day (observed, 7/4 is Saturday) + LocalDate.of(2026, 9, 7), // Labor Day + LocalDate.of(2026, 11, 26), // Thanksgiving Day + LocalDate.of(2026, 12, 25), // Christmas Day + ) + } +} \ No newline at end of file diff --git a/domain/generator/src/main/kotlin/com/few/generator/usecase/NasdaqDailyStockCardSchedulingUseCase.kt b/domain/generator/src/main/kotlin/com/few/generator/usecase/NasdaqDailyStockCardSchedulingUseCase.kt new file mode 100644 index 000000000..47a660f8a --- /dev/null +++ b/domain/generator/src/main/kotlin/com/few/generator/usecase/NasdaqDailyStockCardSchedulingUseCase.kt @@ -0,0 +1,89 @@ +package com.few.generator.usecase + +import com.few.generator.core.instagram.InstagramUploader +import com.few.generator.core.instagram.NasdaqDailyStockCardGenerator +import com.few.generator.core.kis.KisStockFetcher +import com.few.generator.support.aws.S3Provider +import com.few.generator.support.common.NyseMarketCalendar +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Component +import java.io.File +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeFormatter + +@Component +class NasdaqDailyStockCardSchedulingUseCase( + private val kisStockFetcher: KisStockFetcher, + private val nasdaqDailyStockCardGenerator: NasdaqDailyStockCardGenerator, + private val s3Provider: S3Provider, + private val instagramUploader: InstagramUploader, + private val nyseMarketCalendar: NyseMarketCalendar, +) { + private val log = KotlinLogging.logger {} + + fun execute() { + val usDate = LocalDate.now(ZoneId.of("America/New_York")) + if (!nyseMarketCalendar.isTradingDay(usDate)) { + log.info { "NYSE 휴장일($usDate)이므로 스케줄링을 건너뜁니다." } + return + } + + val date = LocalDate.now() + val dateStr = date.format(DateTimeFormatter.ofPattern("yyyyMMdd")) + val outputPath = "gen_images/${dateStr}_nasdaq_daily_stock.png" + + // Step 1: KIS API로 주식 시세 조회 + log.info { "나스닥 주식 시세 조회 시작" } + val stocks = kisStockFetcher.fetchAll() + log.info { "주식 시세 조회 완료 (종목 수: ${stocks.values.sumOf { it.size }})" } + + // Step 2: 카드 이미지 생성 + log.info { "주식 카드 이미지 생성 시작: $outputPath" } + val generated = nasdaqDailyStockCardGenerator.generateImage(stocks, outputPath, date) + if (!generated) { + throw RuntimeException("주식 카드 이미지 생성 실패: $outputPath") + } + log.info { "주식 카드 이미지 생성 완료: $outputPath" } + + // Step 3: S3 업로드 + log.info { "S3 업로드 시작: $outputPath" } + val uploadResult = s3Provider.uploadImages(listOf(outputPath)) + + // Step 4: 로컬 파일 삭제 + File(outputPath).takeIf { it.exists() }?.let { + if (it.delete()) { + log.debug { "로컬 파일 삭제 성공: $outputPath" } + } else { + log.warn { "로컬 파일 삭제 실패: $outputPath" } + } + } + + val s3Url = + uploadResult.successfulUploads.firstOrNull()?.url + ?: throw RuntimeException("S3 업로드 실패: ${uploadResult.getErrorMessage()}") + log.info { "S3 업로드 완료: $s3Url" } + + // Step 5: Instagram 단일 이미지 게시 + val caption = buildCaption(date) + log.info { "Instagram 미디어 컨테이너 생성 시작" } + val containerId = + instagramUploader.createSingleMediaContainer(s3Url, caption) + ?: throw RuntimeException("Instagram 미디어 컨테이너 생성 실패: containerId가 null") + + log.info { "Instagram 게시 시작 (containerId: $containerId)" } + instagramUploader.publishMedia(containerId) + log.info { "Instagram 나스닥 주식 카드 게시 완료" } + } + + private fun buildCaption(date: LocalDate): String { + val dateFormatted = date.format(DateTimeFormatter.ofPattern("yyyy.MM.dd")) + return """ + 📈 일간 미국 지수 | $dateFormatted + + M7 · ETF 주요 종목 시황 + + #나스닥 #미국주식 #NASDAQ #M7 #ETF + """.trimIndent() + } +} \ No newline at end of file diff --git a/domain/generator/src/main/resources/application-generator-local.yaml b/domain/generator/src/main/resources/application-generator-local.yaml index d3cef3d1d..f693ff76a 100644 --- a/domain/generator/src/main/resources/application-generator-local.yaml +++ b/domain/generator/src/main/resources/application-generator-local.yaml @@ -19,6 +19,18 @@ spring: connectTimeout: 10000 readTimeout: 60000 loggerLevel: full + kis-token: + url: ${KIS_API_URL} + defaultRequestHeaders: + Content-Type: "application/json" + appkey: ${KIS_APP_KEY} + appsecret: ${KIS_APP_SECRET} + kis: + url: ${KIS_API_URL} + defaultRequestHeaders: + Content-Type: "application/json" + appkey: ${KIS_APP_KEY} + appsecret: ${KIS_APP_SECRET} openai: url: https://api.openai.com errorDecoder: com.few.generator.config.feign.OpenAiErrorDecoder @@ -51,6 +63,7 @@ scheduling: email: "0 0 9 * * *" cache-metrics: "0 43 17 * * *" instagram-token-refresh: "0 0 2 * * MON" + nasdaq-daily-stock: "0 0 6 * * *" urls: webhook: diff --git a/domain/generator/src/main/resources/application-generator-prd.yaml b/domain/generator/src/main/resources/application-generator-prd.yaml index 1fcad58c9..d5d49394a 100644 --- a/domain/generator/src/main/resources/application-generator-prd.yaml +++ b/domain/generator/src/main/resources/application-generator-prd.yaml @@ -19,6 +19,18 @@ spring: connectTimeout: 10000 readTimeout: 60000 loggerLevel: full + kis-token: + url: ${KIS_API_URL} + defaultRequestHeaders: + Content-Type: "application/json" + appkey: ${KIS_APP_KEY} + appsecret: ${KIS_APP_SECRET} + kis: + url: ${KIS_API_URL} + defaultRequestHeaders: + Content-Type: "application/json" + appkey: ${KIS_APP_KEY} + appsecret: ${KIS_APP_SECRET} openai: url: https://api.openai.com errorDecoder: com.few.generator.config.feign.OpenAiErrorDecoder @@ -49,6 +61,7 @@ scheduling: email: "0 0 9 * * *" cache-metrics: "0 0 3 * * *" instagram-token-refresh: "0 0 2 * * MON" + nasdaq-daily-stock: "0 0 6 * * *" urls: webhook: diff --git a/domain/generator/src/main/resources/images/m7/aapl_logo.png b/domain/generator/src/main/resources/images/m7/aapl_logo.png new file mode 100644 index 000000000..bb96297f9 Binary files /dev/null and b/domain/generator/src/main/resources/images/m7/aapl_logo.png differ diff --git a/domain/generator/src/main/resources/images/m7/amzn_logo.png b/domain/generator/src/main/resources/images/m7/amzn_logo.png new file mode 100644 index 000000000..3a9303756 Binary files /dev/null and b/domain/generator/src/main/resources/images/m7/amzn_logo.png differ diff --git a/domain/generator/src/main/resources/images/m7/googl_logo.png b/domain/generator/src/main/resources/images/m7/googl_logo.png new file mode 100644 index 000000000..0cee844be Binary files /dev/null and b/domain/generator/src/main/resources/images/m7/googl_logo.png differ diff --git a/domain/generator/src/main/resources/images/m7/meta_logo.png b/domain/generator/src/main/resources/images/m7/meta_logo.png new file mode 100644 index 000000000..68b4bb811 Binary files /dev/null and b/domain/generator/src/main/resources/images/m7/meta_logo.png differ diff --git a/domain/generator/src/main/resources/images/m7/msft_logo.png b/domain/generator/src/main/resources/images/m7/msft_logo.png new file mode 100644 index 000000000..0bd299c0e Binary files /dev/null and b/domain/generator/src/main/resources/images/m7/msft_logo.png differ diff --git a/domain/generator/src/main/resources/images/m7/nvda_logo.png b/domain/generator/src/main/resources/images/m7/nvda_logo.png new file mode 100644 index 000000000..e0b889517 Binary files /dev/null and b/domain/generator/src/main/resources/images/m7/nvda_logo.png differ diff --git a/domain/generator/src/main/resources/images/m7/tsla_logo.png b/domain/generator/src/main/resources/images/m7/tsla_logo.png new file mode 100644 index 000000000..0573e7b84 Binary files /dev/null and b/domain/generator/src/main/resources/images/m7/tsla_logo.png differ diff --git a/domain/generator/src/test/kotlin/com/few/generator/core/instagram/NasdaqDailyStockCardGeneratorTest.kt b/domain/generator/src/test/kotlin/com/few/generator/core/instagram/NasdaqDailyStockCardGeneratorTest.kt new file mode 100644 index 000000000..e46aeb685 --- /dev/null +++ b/domain/generator/src/test/kotlin/com/few/generator/core/instagram/NasdaqDailyStockCardGeneratorTest.kt @@ -0,0 +1,144 @@ +package com.few.generator.core.instagram + +import com.few.generator.core.kis.OverseaStockConstants +import com.few.generator.core.kis.StockQuote +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import java.io.File +import java.time.LocalDate + +class NasdaqDailyStockCardGeneratorTest : + FunSpec({ + + val generator = NasdaqDailyStockCardGenerator() + val outputDir = "gen_images" + + fun etfStocks() = + listOf( + StockQuote(symbol = "SPY", koreanName = "S&P500", currentPrice = "525.30", changeRate = "+0.82"), + StockQuote(symbol = "QQQ", koreanName = "나스닥100", currentPrice = "446.12", changeRate = "-0.35"), + StockQuote(symbol = "SCHD", koreanName = "다우존스", currentPrice = "27.44", changeRate = "0.00"), + ) + + fun m7Stocks() = + listOf( + StockQuote(symbol = "AAPL", koreanName = "애플", currentPrice = "189.72", changeRate = "+1.23"), + StockQuote(symbol = "MSFT", koreanName = "마이크로소프트", currentPrice = "415.80", changeRate = "+0.57"), + StockQuote(symbol = "GOOGL", koreanName = "알파벳", currentPrice = "175.40", changeRate = "-0.91"), + StockQuote(symbol = "AMZN", koreanName = "아마존", currentPrice = "192.15", changeRate = "+2.04"), + StockQuote(symbol = "NVDA", koreanName = "엔비디아", currentPrice = "875.00", changeRate = "+3.41"), + StockQuote(symbol = "META", koreanName = "메타", currentPrice = "505.22", changeRate = "-1.18"), + StockQuote(symbol = "TSLA", koreanName = "테슬라", currentPrice = "163.57", changeRate = "0.00"), + ) + + fun fullStocks() = + mapOf( + OverseaStockConstants.StockGroup.ETF to etfStocks(), + OverseaStockConstants.StockGroup.M7 to m7Stocks(), + ) + + test("더미 데이터로 나스닥 주식 카드 이미지를 생성한다") { + val outputPath = "$outputDir/test_nasdaq_stock_card.png" + + val success = generator.generateImage(fullStocks(), outputPath) + + success shouldBe true + val file = File(outputPath) + file.exists() shouldBe true + file.length() shouldBe (file.length().also { assert(it > 0L) { "이미지 파일 크기가 0입니다" } }) + } + + test("marketMood 텍스트가 포함된 이미지를 생성한다") { + val outputPath = "$outputDir/test_nasdaq_stock_card_with_mood.png" + + val success = generator.generateImage(fullStocks(), outputPath, marketMood = "Bullish") + + success shouldBe true + File(outputPath).exists() shouldBe true + } + + test("marketMood 없이 이미지를 생성한다") { + val outputPath = "$outputDir/test_nasdaq_stock_card_no_mood.png" + + val success = generator.generateImage(fullStocks(), outputPath, marketMood = "") + + success shouldBe true + File(outputPath).exists() shouldBe true + } + + test("특정 날짜로 헤더가 렌더링된 이미지를 생성한다") { + val outputPath = "$outputDir/test_nasdaq_stock_card_specific_date.png" + val fixedDate = LocalDate.of(2026, 3, 31) + + val success = generator.generateImage(fullStocks(), outputPath, date = fixedDate) + + success shouldBe true + File(outputPath).exists() shouldBe true + } + + test("ETF 데이터가 없어도 이미지를 생성한다") { + val outputPath = "$outputDir/test_nasdaq_stock_card_no_etf.png" + val stocks = + mapOf( + OverseaStockConstants.StockGroup.M7 to m7Stocks(), + ) + + val success = generator.generateImage(stocks, outputPath) + + success shouldBe true + File(outputPath).exists() shouldBe true + } + + test("M7 데이터가 없어도 이미지를 생성한다") { + val outputPath = "$outputDir/test_nasdaq_stock_card_no_m7.png" + val stocks = + mapOf( + OverseaStockConstants.StockGroup.ETF to etfStocks(), + ) + + val success = generator.generateImage(stocks, outputPath) + + success shouldBe true + File(outputPath).exists() shouldBe true + } + + test("모든 종목이 상승인 경우 이미지를 생성한다") { + val outputPath = "$outputDir/test_nasdaq_stock_card_all_rise.png" + val stocks = + mapOf( + OverseaStockConstants.StockGroup.ETF to + listOf( + StockQuote("SPY", "S&P500", "530.00", "+1.50"), + StockQuote("QQQ", "나스닥100", "450.00", "+2.00"), + StockQuote("SCHD", "다우존스", "28.00", "+0.50"), + ), + OverseaStockConstants.StockGroup.M7 to + m7Stocks().map { it.copy(changeRate = "+${it.changeRate.trimStart('+', '-')}") }, + ) + + val success = generator.generateImage(stocks, outputPath) + + success shouldBe true + File(outputPath).exists() shouldBe true + } + + test("모든 종목이 하락인 경우 이미지를 생성한다") { + val outputPath = "$outputDir/test_nasdaq_stock_card_all_fall.png" + val stocks = + mapOf( + OverseaStockConstants.StockGroup.ETF to + listOf( + StockQuote("SPY", "S&P500", "510.00", "-1.50"), + StockQuote("QQQ", "나스닥100", "430.00", "-2.00"), + StockQuote("SCHD", "다우존스", "26.50", "-0.50"), + ), + OverseaStockConstants.StockGroup.M7 to + m7Stocks().map { it.copy(changeRate = "-${it.changeRate.trimStart('+', '-')}") }, + ) + + val success = generator.generateImage(stocks, outputPath) + + success shouldBe true + File(outputPath).exists() shouldBe true + } + }) \ No newline at end of file diff --git a/domain/generator/src/test/kotlin/com/few/generator/core/instagram/StockCardGeneratorIntegrationTest.kt b/domain/generator/src/test/kotlin/com/few/generator/core/instagram/StockCardGeneratorIntegrationTest.kt new file mode 100644 index 000000000..6436b73ef --- /dev/null +++ b/domain/generator/src/test/kotlin/com/few/generator/core/instagram/StockCardGeneratorIntegrationTest.kt @@ -0,0 +1,126 @@ +package com.few.generator.core.instagram + +import com.few.generator.core.kis.KisClient +import com.few.generator.core.kis.KisStockFetcher +import com.few.generator.core.kis.KisTokenClient +import com.google.gson.Gson +import feign.Feign +import feign.codec.Decoder +import feign.codec.Encoder +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import org.springframework.cloud.openfeign.support.SpringMvcContract +import java.io.File +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +/** + * 실제 KIS API 데이터로 StockCardGenerator 이미지 생성을 검증하는 통합 테스트 + * + * 실행 조건: 아래 환경변수가 모두 설정되어야 합니다. + * - KIS_APP_KEY + * - KIS_APP_SECRET + * - KIS_API_URL (선택, 기본값: https://openapi.koreainvestment.com:9443) + * + * 환경변수 미설정 시 테스트를 건너뜁니다. + * 생성된 이미지는 gen_images/{yyyyMMdd}_nasdaq_stock_test.png 경로에 저장됩니다. + */ +class StockCardGeneratorIntegrationTest : + FunSpec({ + + val appKey = System.getenv("KIS_APP_KEY") ?: "" + val appSecret = System.getenv("KIS_APP_SECRET") ?: "" + val apiUrl = System.getenv("KIS_API_URL") ?: "https://openapi.koreainvestment.com:9443" + + val gson = Gson() + val contract = SpringMvcContract() + + val gsonEncoder = + Encoder { obj, _, template -> + template.body(gson.toJson(obj)) + } + val gsonDecoder = + Decoder { response, type -> + response.body().asReader(Charsets.UTF_8).use { reader -> + gson.fromJson(reader, type) + } + } + + fun buildFetcher(): KisStockFetcher { + val tokenClient = + Feign + .builder() + .contract(contract) + .encoder(gsonEncoder) + .decoder(gsonDecoder) + .target(KisTokenClient::class.java, apiUrl) + + val stockClient = + Feign + .builder() + .contract(contract) + .encoder(gsonEncoder) + .decoder(gsonDecoder) + .requestInterceptor { template -> + template.header("appkey", appKey) + template.header("appsecret", appSecret) + template.header("Content-Type", "application/json") + }.target(KisClient::class.java, apiUrl) + + return KisStockFetcher( + kisTokenClient = tokenClient, + kisClient = stockClient, + appKey = appKey, + appSecret = appSecret, + ) + } + + test("실제 KIS API 데이터로 나스닥 주식 카드 이미지를 생성한다") { + if (appKey.isBlank() || appSecret.isBlank()) { + println("⚠️ KIS_APP_KEY 또는 KIS_APP_SECRET 환경변수가 설정되지 않아 테스트를 건너뜁니다.") + return@test + } + + // 1. 실제 KIS API 조회 + val fetcher = buildFetcher() + val stocks = fetcher.fetchAll() + + println("=== KIS API 조회 결과 (${stocks.values.sumOf { it.size }}개) ===") + stocks.forEach { (group, groupStocks) -> + println("--- $group ---") + groupStocks.forEach { stock -> + val arrow = + when (stock.isRise) { + true -> "▲" + false -> "▼" + null -> "-" + } + println( + "${stock.symbol.padEnd(6)} | ${stock.koreanName.padEnd(12)} | \$${ + stock.currentPrice.padStart(10) + } | $arrow ${stock.changeRate}%", + ) + } + } + + // 2. 이미지 생성 + val dateStr = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")) + val outputPath = "gen_images/${dateStr}_nasdaq_stock_test.png" + + val generator = NasdaqDailyStockCardGenerator() + val success = generator.generateImage(stocks, outputPath) + + // 3. 결과 출력 및 검증 + println("=== 이미지 생성 결과 ===") + println("성공 여부: $success") + println("저장 경로: ${File(outputPath).absolutePath}") + + success shouldBe true + + val outputFile = File(outputPath) + assert(outputFile.exists()) { "이미지 파일이 생성되지 않았습니다: ${outputFile.absolutePath}" } + assert(outputFile.length() > 0L) { "이미지 파일 크기가 0입니다: ${outputFile.absolutePath}" } + + println("파일 크기: ${outputFile.length()} bytes") + } + }) \ No newline at end of file diff --git a/domain/generator/src/test/kotlin/com/few/generator/core/kis/KisStockFetcherIntegrationTest.kt b/domain/generator/src/test/kotlin/com/few/generator/core/kis/KisStockFetcherIntegrationTest.kt new file mode 100644 index 000000000..5a8526bfb --- /dev/null +++ b/domain/generator/src/test/kotlin/com/few/generator/core/kis/KisStockFetcherIntegrationTest.kt @@ -0,0 +1,107 @@ +package com.few.generator.core.kis + +import com.google.gson.Gson +import feign.Feign +import feign.codec.Decoder +import feign.codec.Encoder +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldNotBeEmpty +import io.kotest.matchers.string.shouldNotBeBlank +import org.springframework.cloud.openfeign.support.SpringMvcContract + +/** + * 실제 KIS API를 호출하는 통합 테스트 + * + * 실행 조건: 아래 환경변수가 모두 설정되어야 합니다. + * - KIS_APP_KEY + * - KIS_APP_SECRET + * - KIS_API_URL (선택, 기본값: https://openapi.koreainvestment.com:9443) + * + * 환경변수 미설정 시 테스트를 건너뜁니다. + */ +class KisStockFetcherIntegrationTest : + FunSpec({ + + val appKey = System.getenv("KIS_APP_KEY") ?: "" + val appSecret = System.getenv("KIS_APP_SECRET") ?: "" + val apiUrl = System.getenv("KIS_API_URL") ?: "https://openapi.koreainvestment.com:9443" + + val gson = Gson() + val contract = SpringMvcContract() + + val gsonEncoder = + Encoder { obj, _, template -> + template.body(gson.toJson(obj)) + } + val gsonDecoder = + Decoder { response, type -> + response.body().asReader(Charsets.UTF_8).use { reader -> + gson.fromJson(reader, type) + } + } + + fun buildFetcher(): KisStockFetcher { + val tokenClient = + Feign + .builder() + .contract(contract) + .encoder(gsonEncoder) + .decoder(gsonDecoder) + .target(KisTokenClient::class.java, apiUrl) + + val stockClient = + Feign + .builder() + .contract(contract) + .encoder(gsonEncoder) + .decoder(gsonDecoder) + .requestInterceptor { template -> + template.header("appkey", appKey) + template.header("appsecret", appSecret) + template.header("Content-Type", "application/json") + }.target(KisClient::class.java, apiUrl) + + return KisStockFetcher( + kisTokenClient = tokenClient, + kisClient = stockClient, + appKey = appKey, + appSecret = appSecret, + ) + } + + test("실제 KIS API로 Nasdaq 전체 종목 데이터를 조회한다") { + if (appKey.isBlank() || appSecret.isBlank()) { + println("⚠️ KIS_APP_KEY 또는 KIS_APP_SECRET 환경변수가 설정되지 않아 테스트를 건너뜁니다.") + return@test + } + + val fetcher = buildFetcher() + val result = fetcher.fetchAll() + val allStocks = result.values.flatten() + + println("=== KIS API 조회 결과 (${allStocks.size}개) ===") + result.forEach { (group, stocks) -> + println("--- $group ---") + stocks.forEach { stock -> + val arrow = + when (stock.isRise) { + true -> "▲" + false -> "▼" + null -> "-" + } + println( + "${stock.symbol.padEnd( + 6, + )} | ${stock.koreanName.padEnd(10)} | \$${stock.currentPrice.padStart(10)} | $arrow ${stock.changeRate}%", + ) + } + } + + allStocks.shouldNotBeEmpty() + allStocks.forEach { stock -> + stock.symbol.shouldNotBeBlank() + stock.currentPrice.shouldNotBeBlank() + stock.changeRate.shouldNotBeBlank() + } + } + }) \ No newline at end of file diff --git a/domain/generator/src/test/kotlin/com/few/generator/core/kis/KisStockFetcherTest.kt b/domain/generator/src/test/kotlin/com/few/generator/core/kis/KisStockFetcherTest.kt new file mode 100644 index 000000000..89a02da98 --- /dev/null +++ b/domain/generator/src/test/kotlin/com/few/generator/core/kis/KisStockFetcherTest.kt @@ -0,0 +1,230 @@ +package com.few.generator.core.kis + +import com.few.generator.core.kis.dto.KisStockPriceResponse +import com.few.generator.core.kis.dto.KisTokenResponse +import feign.FeignException +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify + +class KisStockFetcherTest : + BehaviorSpec({ + + val kisTokenClient = mockk() + val kisClient = mockk() + val appKey = "test-app-key" + val appSecret = "test-app-secret" + + val fetcher = + KisStockFetcher( + kisTokenClient = kisTokenClient, + kisClient = kisClient, + appKey = appKey, + appSecret = appSecret, + ) + + val mockTokenResponse = + KisTokenResponse( + accessToken = "mock-access-token", + tokenType = "Bearer", + expiresIn = 86400L, + ) + + fun mockStockResponse( + changeRate: String = "+1.00", + last: String = "100.00", + ) = KisStockPriceResponse( + rtCd = "0", + msg1 = "정상처리 되었습니다.", + output = + KisStockPriceResponse.Output( + last = last, + t_xrat = changeRate, + ), + ) + + // 모든 종목을 주어진 changeRate로 세팅 + fun stubAllStocks(changeRate: String = "+1.00") { + OverseaStockConstants.DAILY_NASDAQ_STOCK_GROUP_MAP.values.flatten().forEach { stock -> + every { + kisClient.getStockPrice( + authorization = any(), + trId = any(), + excd = stock.excd, + symb = stock.symbol, + ) + } returns mockStockResponse(changeRate = changeRate) + } + } + + // AAPL만 다른 changeRate, 나머지는 보합으로 세팅 + fun stubAllStocksWithAaplRate(aaplRate: String) { + OverseaStockConstants.DAILY_NASDAQ_STOCK_GROUP_MAP.values.flatten().forEach { stock -> + val rate = if (stock.symbol == "AAPL") aaplRate else "0.00" + every { + kisClient.getStockPrice( + authorization = any(), + trId = any(), + excd = stock.excd, + symb = stock.symbol, + ) + } returns mockStockResponse(changeRate = rate) + } + } + + // 매 Then 실행 전 모든 mock 초기화 (stub 누적 방지) + beforeEach { clearAllMocks() } + + Given("토큰 발급과 전체 종목 조회가 모두 성공할 때") { + beforeEach { + every { kisTokenClient.getToken(any()) } returns mockTokenResponse + stubAllStocks() + } + + When("fetchAll()을 호출하면") { + Then("전체 종목 수만큼 결과를 반환한다") { + val result = fetcher.fetchAll() + result.values.sumOf { it.size } shouldBe + OverseaStockConstants.DAILY_NASDAQ_STOCK_GROUP_MAP.values + .flatten() + .size + } + + Then("토큰 발급을 1회만 호출한다") { + fetcher.fetchAll() + verify(exactly = 1) { kisTokenClient.getToken(any()) } + } + + Then("AAPL 데이터가 올바르게 매핑된다") { + val result = fetcher.fetchAll() + val aapl = result.values.flatten().first { it.symbol == "AAPL" } + aapl.koreanName shouldBe "애플" + aapl.currentPrice shouldBe "100.00" + aapl.changeRate shouldBe "+1.00" + } + } + } + + Given("changeRate가 '+'로 시작할 때") { + beforeEach { + every { kisTokenClient.getToken(any()) } returns mockTokenResponse + stubAllStocksWithAaplRate("+1.00") + } + + When("fetchAll()을 호출하면") { + Then("AAPL의 isRise가 true이다") { + val result = fetcher.fetchAll() + result.values + .flatten() + .first { it.symbol == "AAPL" } + .isRise shouldBe true + } + } + } + + Given("changeRate가 '-'로 시작할 때") { + beforeEach { + every { kisTokenClient.getToken(any()) } returns mockTokenResponse + stubAllStocksWithAaplRate("-1.00") + } + + When("fetchAll()을 호출하면") { + Then("AAPL의 isRise가 false이다") { + val result = fetcher.fetchAll() + result.values + .flatten() + .first { it.symbol == "AAPL" } + .isRise shouldBe false + } + } + } + + Given("changeRate가 부호 없이 '0'일 때") { + beforeEach { + every { kisTokenClient.getToken(any()) } returns mockTokenResponse + stubAllStocks("0.00") + } + + When("fetchAll()을 호출하면") { + Then("AAPL의 isRise가 null이다") { + val result = fetcher.fetchAll() + result.values + .flatten() + .first { it.symbol == "AAPL" } + .isRise + .shouldBeNull() + } + } + } + + Given("일부 종목 API 호출이 FeignException으로 실패할 때") { + beforeEach { + every { kisTokenClient.getToken(any()) } returns mockTokenResponse + OverseaStockConstants.DAILY_NASDAQ_STOCK_GROUP_MAP.values.flatten().forEachIndexed { index, stock -> + if (index % 2 == 0) { + every { + kisClient.getStockPrice(any(), any(), stock.excd, stock.symbol) + } returns mockStockResponse() + } else { + every { + kisClient.getStockPrice(any(), any(), stock.excd, stock.symbol) + } throws RuntimeException("simulated feign error") + } + } + } + + When("fetchAll()을 호출하면") { + Then("성공한 종목만 반환한다") { + val result = fetcher.fetchAll() + val expectedCount = + OverseaStockConstants.DAILY_NASDAQ_STOCK_GROUP_MAP.values + .flatten() + .filterIndexed { index, _ -> + index % + 2 == + 0 + }.size + result.values.sumOf { it.size } shouldBe expectedCount + } + } + } + + Given("모든 종목이 rt_cd 실패 응답을 반환할 때") { + beforeEach { + every { kisTokenClient.getToken(any()) } returns mockTokenResponse + OverseaStockConstants.DAILY_NASDAQ_STOCK_GROUP_MAP.values.flatten().forEach { stock -> + every { + kisClient.getStockPrice(any(), any(), stock.excd, stock.symbol) + } returns KisStockPriceResponse(rtCd = "1", msg1 = "오류", output = null) + } + } + + When("fetchAll()을 호출하면") { + Then("빈 리스트를 반환한다") { + val result = fetcher.fetchAll() + result.values.sumOf { it.size } shouldBe 0 + } + } + } + + Given("토큰 발급이 실패할 때") { + beforeEach { + every { + kisTokenClient.getToken(any()) + } throws mockk(relaxed = true) + } + + When("fetchAll()을 호출하면") { + Then("FeignException이 전파된다") { + shouldThrow { + fetcher.fetchAll() + } + } + } + } + }) \ No newline at end of file diff --git a/domain/generator/src/test/kotlin/com/few/generator/usecase/NasdaqDailyStockCardSchedulingUseCaseTest.kt b/domain/generator/src/test/kotlin/com/few/generator/usecase/NasdaqDailyStockCardSchedulingUseCaseTest.kt new file mode 100644 index 000000000..cd4d27dc7 --- /dev/null +++ b/domain/generator/src/test/kotlin/com/few/generator/usecase/NasdaqDailyStockCardSchedulingUseCaseTest.kt @@ -0,0 +1,245 @@ +package com.few.generator.usecase + +import com.few.generator.core.instagram.InstagramUploader +import com.few.generator.core.instagram.NasdaqDailyStockCardGenerator +import com.few.generator.core.kis.KisStockFetcher +import com.few.generator.core.kis.OverseaStockConstants +import com.few.generator.core.kis.StockQuote +import com.few.generator.support.aws.FailedUpload +import com.few.generator.support.aws.S3Provider +import com.few.generator.support.aws.S3UploadResult +import com.few.generator.support.aws.SuccessfulUpload +import com.few.generator.support.common.NyseMarketCalendar +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.core.spec.style.BehaviorSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify + +class NasdaqDailyStockCardSchedulingUseCaseTest : + BehaviorSpec({ + + val dummyStocks = + mapOf( + OverseaStockConstants.StockGroup.ETF to + listOf( + StockQuote(symbol = "SPY", koreanName = "S&P500 ETF", currentPrice = "500.00", changeRate = "+0.5"), + ), + OverseaStockConstants.StockGroup.M7 to + listOf( + StockQuote(symbol = "AAPL", koreanName = "애플", currentPrice = "200.00", changeRate = "+1.0"), + ), + ) + + Given("KIS API 조회, 이미지 생성, S3 업로드, Instagram 게시가 모두 성공하는 경우") { + val kisStockFetcher = mockk() + val nasdaqDailyStockCardGenerator = mockk() + val s3Provider = mockk() + val instagramUploader = mockk() + val nyseMarketCalendar = mockk() + val useCase = + NasdaqDailyStockCardSchedulingUseCase( + kisStockFetcher = kisStockFetcher, + nasdaqDailyStockCardGenerator = nasdaqDailyStockCardGenerator, + s3Provider = s3Provider, + instagramUploader = instagramUploader, + nyseMarketCalendar = nyseMarketCalendar, + ) + + val s3Url = "https://gen-cards.s3.ap-northeast-2.amazonaws.com/image.png" + + every { nyseMarketCalendar.isTradingDay(any()) } returns true + every { kisStockFetcher.fetchAll() } returns dummyStocks + every { nasdaqDailyStockCardGenerator.generateImage(any(), any(), any()) } returns true + every { s3Provider.uploadImages(any()) } returns + S3UploadResult( + successfulUploads = listOf(SuccessfulUpload(path = "gen_images/test.png", url = s3Url)), + failedUploads = emptyList(), + ) + every { instagramUploader.createSingleMediaContainer(s3Url, any()) } returns "container-id-123" + every { instagramUploader.publishMedia("container-id-123") } returns true + + When("execute를 호출하면") { + Then("모든 단계가 순서대로 실행된다") { + useCase.execute() + + verify(exactly = 1) { kisStockFetcher.fetchAll() } + verify(exactly = 1) { nasdaqDailyStockCardGenerator.generateImage(any(), any(), any()) } + verify(exactly = 1) { s3Provider.uploadImages(any()) } + verify(exactly = 1) { instagramUploader.createSingleMediaContainer(s3Url, any()) } + verify(exactly = 1) { instagramUploader.publishMedia("container-id-123") } + } + } + } + + Given("이미지 생성이 실패하는 경우") { + val kisStockFetcher = mockk() + val nasdaqDailyStockCardGenerator = mockk() + val s3Provider = mockk() + val instagramUploader = mockk() + val nyseMarketCalendar = mockk() + val useCase = + NasdaqDailyStockCardSchedulingUseCase( + kisStockFetcher = kisStockFetcher, + nasdaqDailyStockCardGenerator = nasdaqDailyStockCardGenerator, + s3Provider = s3Provider, + instagramUploader = instagramUploader, + nyseMarketCalendar = nyseMarketCalendar, + ) + + every { nyseMarketCalendar.isTradingDay(any()) } returns true + every { kisStockFetcher.fetchAll() } returns dummyStocks + every { nasdaqDailyStockCardGenerator.generateImage(any(), any(), any()) } returns false + + When("execute를 호출하면") { + Then("RuntimeException이 발생하고 S3 업로드는 호출되지 않는다") { + shouldThrow { useCase.execute() } + + verify(exactly = 0) { s3Provider.uploadImages(any()) } + verify(exactly = 0) { instagramUploader.createSingleMediaContainer(any(), any()) } + } + } + } + + Given("S3 업로드가 실패하는 경우") { + val kisStockFetcher = mockk() + val nasdaqDailyStockCardGenerator = mockk() + val s3Provider = mockk() + val instagramUploader = mockk() + val nyseMarketCalendar = mockk() + val useCase = + NasdaqDailyStockCardSchedulingUseCase( + kisStockFetcher = kisStockFetcher, + nasdaqDailyStockCardGenerator = nasdaqDailyStockCardGenerator, + s3Provider = s3Provider, + instagramUploader = instagramUploader, + nyseMarketCalendar = nyseMarketCalendar, + ) + + every { nyseMarketCalendar.isTradingDay(any()) } returns true + every { kisStockFetcher.fetchAll() } returns dummyStocks + every { nasdaqDailyStockCardGenerator.generateImage(any(), any(), any()) } returns true + every { s3Provider.uploadImages(any()) } returns + S3UploadResult( + successfulUploads = emptyList(), + failedUploads = listOf(FailedUpload(path = "gen_images/test.png", errorMessage = "upload failed")), + ) + + When("execute를 호출하면") { + Then("RuntimeException이 발생하고 Instagram 게시는 호출되지 않는다") { + shouldThrow { useCase.execute() } + + verify(exactly = 0) { instagramUploader.createSingleMediaContainer(any(), any()) } + verify(exactly = 0) { instagramUploader.publishMedia(any()) } + } + } + } + + Given("Instagram 컨테이너 생성이 실패하는 경우 (containerId가 null)") { + val kisStockFetcher = mockk() + val nasdaqDailyStockCardGenerator = mockk() + val s3Provider = mockk() + val instagramUploader = mockk() + val nyseMarketCalendar = mockk() + val useCase = + NasdaqDailyStockCardSchedulingUseCase( + kisStockFetcher = kisStockFetcher, + nasdaqDailyStockCardGenerator = nasdaqDailyStockCardGenerator, + s3Provider = s3Provider, + instagramUploader = instagramUploader, + nyseMarketCalendar = nyseMarketCalendar, + ) + + val s3Url = "https://gen-cards.s3.ap-northeast-2.amazonaws.com/image.png" + + every { nyseMarketCalendar.isTradingDay(any()) } returns true + every { kisStockFetcher.fetchAll() } returns dummyStocks + every { nasdaqDailyStockCardGenerator.generateImage(any(), any(), any()) } returns true + every { s3Provider.uploadImages(any()) } returns + S3UploadResult( + successfulUploads = listOf(SuccessfulUpload(path = "gen_images/test.png", url = s3Url)), + failedUploads = emptyList(), + ) + every { instagramUploader.createSingleMediaContainer(s3Url, any()) } returns null + + When("execute를 호출하면") { + Then("RuntimeException이 발생하고 publishMedia는 호출되지 않는다") { + shouldThrow { useCase.execute() } + + verify(exactly = 0) { instagramUploader.publishMedia(any()) } + } + } + } + + Given("buildCaption이 올바른 캡션을 생성하는 경우") { + val kisStockFetcher = mockk() + val nasdaqDailyStockCardGenerator = mockk() + val s3Provider = mockk() + val instagramUploader = mockk() + val nyseMarketCalendar = mockk() + val useCase = + NasdaqDailyStockCardSchedulingUseCase( + kisStockFetcher = kisStockFetcher, + nasdaqDailyStockCardGenerator = nasdaqDailyStockCardGenerator, + s3Provider = s3Provider, + instagramUploader = instagramUploader, + nyseMarketCalendar = nyseMarketCalendar, + ) + + val s3Url = "https://gen-cards.s3.ap-northeast-2.amazonaws.com/image.png" + val captionSlot = mutableListOf() + + every { nyseMarketCalendar.isTradingDay(any()) } returns true + every { kisStockFetcher.fetchAll() } returns dummyStocks + every { nasdaqDailyStockCardGenerator.generateImage(any(), any(), any()) } returns true + every { s3Provider.uploadImages(any()) } returns + S3UploadResult( + successfulUploads = listOf(SuccessfulUpload(path = "gen_images/test.png", url = s3Url)), + failedUploads = emptyList(), + ) + every { instagramUploader.createSingleMediaContainer(s3Url, capture(captionSlot)) } returns "container-id" + every { instagramUploader.publishMedia(any()) } returns true + + When("execute를 호출하면") { + Then("캡션에 '나스닥 데일리'와 해시태그가 포함된다") { + useCase.execute() + + val caption = captionSlot.first() + caption.contains("나스닥 데일리") shouldBe true + caption.contains("#나스닥") shouldBe true + caption.contains("#NASDAQ") shouldBe true + } + } + } + + Given("NYSE 휴장일인 경우") { + val kisStockFetcher = mockk() + val nasdaqDailyStockCardGenerator = mockk() + val s3Provider = mockk() + val instagramUploader = mockk() + val nyseMarketCalendar = mockk() + val useCase = + NasdaqDailyStockCardSchedulingUseCase( + kisStockFetcher = kisStockFetcher, + nasdaqDailyStockCardGenerator = nasdaqDailyStockCardGenerator, + s3Provider = s3Provider, + instagramUploader = instagramUploader, + nyseMarketCalendar = nyseMarketCalendar, + ) + + every { nyseMarketCalendar.isTradingDay(any()) } returns false + + When("execute를 호출하면") { + Then("모든 다운스트림 작업이 호출되지 않는다") { + useCase.execute() + + verify(exactly = 0) { kisStockFetcher.fetchAll() } + verify(exactly = 0) { nasdaqDailyStockCardGenerator.generateImage(any(), any(), any()) } + verify(exactly = 0) { s3Provider.uploadImages(any()) } + verify(exactly = 0) { instagramUploader.createSingleMediaContainer(any(), any()) } + verify(exactly = 0) { instagramUploader.publishMedia(any()) } + } + } + } + }) \ No newline at end of file diff --git a/domain/generator/src/test/kotlin/com/few/generator/usecase/NasdaqStockCardS3UploadIntegrationTest.kt b/domain/generator/src/test/kotlin/com/few/generator/usecase/NasdaqStockCardS3UploadIntegrationTest.kt new file mode 100644 index 000000000..84f5db110 --- /dev/null +++ b/domain/generator/src/test/kotlin/com/few/generator/usecase/NasdaqStockCardS3UploadIntegrationTest.kt @@ -0,0 +1,190 @@ +package com.few.generator.usecase + +import com.few.generator.core.instagram.NasdaqDailyStockCardGenerator +import com.few.generator.core.kis.KisClient +import com.few.generator.core.kis.KisStockFetcher +import com.few.generator.core.kis.KisTokenClient +import com.few.generator.support.aws.S3Provider +import com.google.gson.Gson +import feign.Feign +import feign.codec.Decoder +import feign.codec.Encoder +import io.awspring.cloud.s3.DiskBufferingS3OutputStreamProvider +import io.awspring.cloud.s3.S3ObjectConverter +import io.awspring.cloud.s3.S3Template +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import org.springframework.cloud.openfeign.support.SpringMvcContract +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider +import software.amazon.awssdk.core.sync.RequestBody +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.s3.S3Client +import software.amazon.awssdk.services.s3.presigner.S3Presigner +import java.io.File +import java.io.InputStream +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +/** + * 나스닥 주식 카드 이미지 생성 후 S3 업로드 통합 테스트 + * + * 실행 조건: 아래 환경변수가 모두 설정되어야 합니다. + * - KIS_APP_KEY + * - KIS_APP_SECRET + * - STORAGE_ACCESS_KEY + * - STORAGE_SECRET_KEY + * - KIS_API_URL (선택, 기본값: https://openapi.koreainvestment.com:9443) + * + * 환경변수 미설정 시 테스트를 건너뜁니다. + */ +class NasdaqStockCardS3UploadIntegrationTest : + FunSpec({ + + val kisAppKey = System.getenv("KIS_APP_KEY") ?: "" + val kisAppSecret = System.getenv("KIS_APP_SECRET") ?: "" + val kisApiUrl = System.getenv("KIS_API_URL") ?: "https://openapi.koreainvestment.com:9443" + val storageAccessKey = System.getenv("STORAGE_ACCESS_KEY") ?: "" + val storageSecretKey = System.getenv("STORAGE_SECRET_KEY") ?: "" + val bucket = "gen-cards" + + val gson = Gson() + val contract = SpringMvcContract() + + val gsonEncoder = + Encoder { obj, _, template -> + template.body(gson.toJson(obj)) + } + val gsonDecoder = + Decoder { response, type -> + response.body().asReader(Charsets.UTF_8).use { reader -> + gson.fromJson(reader, type) + } + } + + fun buildFetcher(): KisStockFetcher { + val tokenClient = + Feign + .builder() + .contract(contract) + .encoder(gsonEncoder) + .decoder(gsonDecoder) + .target(KisTokenClient::class.java, kisApiUrl) + + val stockClient = + Feign + .builder() + .contract(contract) + .encoder(gsonEncoder) + .decoder(gsonDecoder) + .requestInterceptor { template -> + template.header("appkey", kisAppKey) + template.header("appsecret", kisAppSecret) + template.header("Content-Type", "application/json") + }.target(KisClient::class.java, kisApiUrl) + + return KisStockFetcher( + kisTokenClient = tokenClient, + kisClient = stockClient, + appKey = kisAppKey, + appSecret = kisAppSecret, + ) + } + + fun buildS3Provider(): S3Provider { + val s3Client = + S3Client + .builder() + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create(storageAccessKey, storageSecretKey), + ), + ).region(Region.AP_NORTHEAST_2) + .build() + + val s3OutputStreamProvider = DiskBufferingS3OutputStreamProvider(s3Client, null) + + val noopConverter = + object : S3ObjectConverter { + @Suppress("UNCHECKED_CAST") + override fun read( + inputStream: InputStream, + clazz: Class, + ): T = throw UnsupportedOperationException() + + override fun write(obj: T): RequestBody = throw UnsupportedOperationException() + + override fun contentType(): String = throw UnsupportedOperationException() + } + + val s3Presigner = + S3Presigner + .builder() + .credentialsProvider( + StaticCredentialsProvider.create( + AwsBasicCredentials.create(storageAccessKey, storageSecretKey), + ), + ).region(Region.AP_NORTHEAST_2) + .build() + + return S3Provider(S3Template(s3Client, s3OutputStreamProvider, noopConverter, s3Presigner), bucket) + } + + test("나스닥 주식 카드 이미지를 생성하고 S3에 업로드한다") { + if (kisAppKey.isBlank() || kisAppSecret.isBlank()) { + println("⚠️ KIS_APP_KEY 또는 KIS_APP_SECRET 환경변수가 설정되지 않아 테스트를 건너뜁니다.") + return@test + } + if (storageAccessKey.isBlank() || storageSecretKey.isBlank()) { + println("⚠️ STORAGE_ACCESS_KEY 또는 STORAGE_SECRET_KEY 환경변수가 설정되지 않아 테스트를 건너뜁니다.") + return@test + } + + // Step 1: KIS API로 주식 시세 조회 + val fetcher = buildFetcher() + val stocks = fetcher.fetchAll() + println("=== KIS API 조회 결과 (${stocks.values.sumOf { it.size }}개) ===") + stocks.forEach { (group, groupStocks) -> + println("--- $group ---") + groupStocks.forEach { stock -> + val arrow = + when (stock.isRise) { + true -> "▲" + false -> "▼" + null -> "-" + } + println( + "${stock.symbol.padEnd( + 6, + )} | ${stock.koreanName.padEnd(12)} | \$${stock.currentPrice.padStart(10)} | $arrow ${stock.changeRate}%", + ) + } + } + + // Step 2: 이미지 생성 + val dateStr = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyyMMdd")) + val outputPath = "gen_images/${dateStr}_nasdaq_daily_stock_s3_test.png" + + val generator = NasdaqDailyStockCardGenerator() + val generated = generator.generateImage(stocks, outputPath) + println("=== 이미지 생성 결과 ===") + println("성공 여부: $generated / 경로: ${File(outputPath).absolutePath}") + generated shouldBe true + + // Step 3: S3 업로드 + val s3Provider = buildS3Provider() + val uploadResult = s3Provider.uploadImages(listOf(outputPath)) + println("=== S3 업로드 결과 ===") + println("성공: ${uploadResult.uploadedCount}개 / 실패: ${uploadResult.failedCount}개") + uploadResult.successfulUploads.forEach { println("URL: ${it.url}") } + uploadResult.failedUploads.forEach { println("실패: ${it.path} - ${it.errorMessage}") } + + // Step 4: 로컬 파일 정리 + File(outputPath).takeIf { it.exists() }?.delete() + + // Step 5: 검증 + uploadResult.uploadedCount shouldBe 1 + uploadResult.successfulUploads.first().url shouldNotBe null + } + }) \ No newline at end of file