diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab08419..f9fe2f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -104,4 +104,4 @@ jobs: ${{ runner.os }}-gradle- - name: Run platform tests - run: ./gradlew :platform-api:test :auth-domain:test :auth-application:test :auth-adapter:test :member-domain:test :member-application:test :member-adapter:test \ No newline at end of file + run: ./gradlew :platform-api:test :auth-domain:test :auth-application:test :auth-adapter:test :member-domain:test :member-application:test :member-adapter:test :template-adapter:test :template-application:test :template-domain:test :ai-adapter:test :ai-application:test :ai-domain:test \ No newline at end of file diff --git a/libs/security/src/main/kotlin/app/cardcapture/lib/security/SecurityConfig.kt b/libs/security/src/main/kotlin/app/cardcapture/lib/security/SecurityConfig.kt index 32bb2c9..8aa8d10 100644 --- a/libs/security/src/main/kotlin/app/cardcapture/lib/security/SecurityConfig.kt +++ b/libs/security/src/main/kotlin/app/cardcapture/lib/security/SecurityConfig.kt @@ -22,7 +22,7 @@ class SecurityConfig( private val WHITE_LIST = arrayOf( "/actuator/**", "/api/v1/dev/auth/**", - "/api/v1/auth/**" + "/api/v1/auth/**", ) } @Bean diff --git a/platform/ai/adapter/build.gradle.kts b/platform/ai/adapter/build.gradle.kts new file mode 100644 index 0000000..8ca760e --- /dev/null +++ b/platform/ai/adapter/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + kotlin("jvm") + kotlin("plugin.spring") + kotlin("plugin.jpa") + id("io.spring.dependency-management") +} + +dependencies { + + implementation(project(":ai-domain")) + implementation(project(":ai-application")) + implementation(project(":security")) + + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-webflux") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("io.awspring.cloud:spring-cloud-aws-s3:3.4.0") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("org.jetbrains.kotlin:kotlin-reflect") + + // --- test --- + testImplementation("org.springframework.boot:spring-boot-starter-test") { + exclude(group = "org.mockito") + } + testImplementation("com.ninja-squad:springmockk:latest.release") + +} diff --git a/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/imageai/google/GoogleAiConfig.kt b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/imageai/google/GoogleAiConfig.kt new file mode 100644 index 0000000..e2ab914 --- /dev/null +++ b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/imageai/google/GoogleAiConfig.kt @@ -0,0 +1,31 @@ +package app.cardcapture.ai.outbound.imageai.google + +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.web.reactive.function.client.ExchangeStrategies +import org.springframework.web.reactive.function.client.WebClient + +@Configuration +class GoogleAiConfig ( + @Value("\${google.key}") private val apiKey: String +){ + + private val baseUrl = "https://generativelanguage.googleapis.com" + private val keyHeader = "x-goog-api-key" + + @Bean + fun googleImageWebClient(): WebClient{ + val strategies = ExchangeStrategies.builder() + .codecs { config -> + config.defaultCodecs().maxInMemorySize(10 * 1024 * 1024) + } + .build() + + return WebClient.builder() + .defaultHeader(keyHeader, apiKey) + .baseUrl(baseUrl) + .exchangeStrategies(strategies) + .build() + } +} diff --git a/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/imageai/google/ImageModelAdapter.kt b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/imageai/google/ImageModelAdapter.kt new file mode 100644 index 0000000..f0af4d8 --- /dev/null +++ b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/imageai/google/ImageModelAdapter.kt @@ -0,0 +1,51 @@ +package app.cardcapture.ai.outbound.imageai.google + +import app.cardcapture.ai.outbound.imageai.google.dto.GeminiPreviewImageRequest +import app.cardcapture.ai.outbound.imageai.google.dto.GeminiPreviewImageResponse +import app.cardcapture.ai.port.outbound.ImageModelPort +import org.springframework.http.HttpStatusCode +import org.springframework.stereotype.Component +import org.springframework.web.reactive.function.client.WebClient +import reactor.core.publisher.Mono +import java.util.* + +@Component +class ImageModelAdapter( + private val googleImageWebClient: WebClient, +) : ImageModelPort { + + override fun generate(prompt: String, model: String): ByteArray { + + val request = GeminiPreviewImageRequest( + contents = listOf( + GeminiPreviewImageRequest.ImagerPartsBlock( + parts = listOf(GeminiPreviewImageRequest.TextBlock(prompt)), + ), + ), + ) + + val response = googleImageWebClient.post() + .uri("v1beta/models/gemini-2.5-flash-image-preview:generateContent") + .bodyValue(request) + .retrieve() + .onStatus(HttpStatusCode::isError) { res -> + res.bodyToMono(String::class.java).flatMap { body -> + val msg = "Gemini image API error: ${res.statusCode()} body=$body" + Mono.error(IllegalStateException(msg)) + } + } + .bodyToMono(GeminiPreviewImageResponse::class.java) + .block() ?: error("Empty response from Gemini image API") + + + val base64Data = response.candidates + .firstOrNull() + ?.content + ?.parts + ?.firstOrNull { it.inlineData != null } + ?.inlineData + ?.data + ?: error("Image data missing") + return Base64.getDecoder().decode(base64Data) + } +} diff --git a/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/imageai/google/dto/GeminiPreviewImageRequest.kt b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/imageai/google/dto/GeminiPreviewImageRequest.kt new file mode 100644 index 0000000..b8b557b --- /dev/null +++ b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/imageai/google/dto/GeminiPreviewImageRequest.kt @@ -0,0 +1,14 @@ +package app.cardcapture.ai.outbound.imageai.google.dto + +data class GeminiPreviewImageRequest( + val contents: List +) { + + data class ImagerPartsBlock( + val parts: List + ) + + data class TextBlock( + val text: String + ) +} diff --git a/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/imageai/google/dto/GeminiPreviewImageResponse.kt b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/imageai/google/dto/GeminiPreviewImageResponse.kt new file mode 100644 index 0000000..71cdbb6 --- /dev/null +++ b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/imageai/google/dto/GeminiPreviewImageResponse.kt @@ -0,0 +1,25 @@ +package app.cardcapture.ai.outbound.imageai.google.dto + + +data class GeminiPreviewImageResponse( + val candidates: List, + val modelVersion: String? = null +) { + data class Candidate( + val content: Content? + ) + + data class Content( + val parts: List? + ) + + data class Part( + val text: String? = null, + val inlineData: InlineData? = null + ) + + data class InlineData( + val mimeType: String? = null, + val data: String? = null + ) +} diff --git a/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/LlmSenderAdapter.kt b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/LlmSenderAdapter.kt new file mode 100644 index 0000000..8d6d492 --- /dev/null +++ b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/LlmSenderAdapter.kt @@ -0,0 +1,67 @@ +package app.cardcapture.ai.outbound.llm.openai + +import app.cardcapture.ai.domain.LlmRequest +import app.cardcapture.ai.domain.LlmResponse +import app.cardcapture.ai.outbound.llm.openai.dto.GptCompletionMessageRequest +import app.cardcapture.ai.outbound.llm.openai.dto.GptCompletionRequest +import app.cardcapture.ai.outbound.llm.openai.dto.GptMessageRole +import app.cardcapture.ai.outbound.llm.openai.dto.GptResponseFormat +import app.cardcapture.ai.port.outbound.LlmSenderPort +import com.fasterxml.jackson.databind.ObjectMapper +import org.springframework.http.HttpStatusCode +import org.springframework.stereotype.Component +import org.springframework.web.reactive.function.client.WebClient +import reactor.core.publisher.Mono + + +@Component +class LlmSenderAdapter( + private val openAiWebClient: WebClient, + private val objectMapper: ObjectMapper +) : LlmSenderPort { + + override fun send(request: LlmRequest): LlmResponse { + + + val request = GptCompletionRequest( + model = request.model.model, + stream = false, + messages = listOf( + GptCompletionMessageRequest( + role = GptMessageRole.SYSTEM, + content = request.system, + ), + GptCompletionMessageRequest( + role = GptMessageRole.USER, + content = request.user, + ), + ), + response_format = GptResponseFormat( + type = "json_object", + ), + ) + + val result = openAiWebClient.post() + .uri("/chat/completions") + .bodyValue(request) + .retrieve() + .onStatus(HttpStatusCode::isError) { res -> + res.bodyToMono(String::class.java).flatMap { body -> + val msg = "Open AI error : ${res.statusCode()} body = $body" + Mono.error(IllegalStateException(msg)) + } + } + .bodyToMono(String::class.java) + .block() + val content = objectMapper.readTree(result)["choices"][0]["message"]["content"].asText() + + /* + * TODO: content error handling ( empty value ) + */ + + return LlmResponse( + jsonValueRaw = content, + resourceUsage = null, + ) + } +} diff --git a/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/OpenAiConfig.kt b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/OpenAiConfig.kt new file mode 100644 index 0000000..2eabe8e --- /dev/null +++ b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/OpenAiConfig.kt @@ -0,0 +1,27 @@ +package app.cardcapture.ai.outbound.llm.openai + +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.http.HttpHeaders +import org.springframework.web.reactive.function.client.WebClient + +@Configuration +class OpenAiConfig ( + @Value("\${openai.key}") private val apiKey: String +){ + + private val baseUrl = "https://api.openai.com/v1" + + @Bean + fun openAiWebClient(): WebClient { + /* + * @TODO: exception Handling + */ + return WebClient.builder() + .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer $apiKey") + .baseUrl(baseUrl) + .build() + } + +} diff --git a/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/dto/GptCompletionMessageRequest.kt b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/dto/GptCompletionMessageRequest.kt new file mode 100644 index 0000000..a18b426 --- /dev/null +++ b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/dto/GptCompletionMessageRequest.kt @@ -0,0 +1,7 @@ +package app.cardcapture.ai.outbound.llm.openai.dto + +data class GptCompletionMessageRequest( + val role : GptMessageRole, + val content: String, +) { +} diff --git a/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/dto/GptCompletionRequest.kt b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/dto/GptCompletionRequest.kt new file mode 100644 index 0000000..f2ad636 --- /dev/null +++ b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/dto/GptCompletionRequest.kt @@ -0,0 +1,9 @@ +package app.cardcapture.ai.outbound.llm.openai.dto + +data class GptCompletionRequest( + val messages: List, + val model: String, + val stream: Boolean, + val response_format: GptResponseFormat +) { +} diff --git a/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/dto/GptMessageRole.kt b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/dto/GptMessageRole.kt new file mode 100644 index 0000000..b2dd474 --- /dev/null +++ b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/dto/GptMessageRole.kt @@ -0,0 +1,14 @@ +package app.cardcapture.ai.outbound.llm.openai.dto + +import com.fasterxml.jackson.annotation.JsonValue + +enum class GptMessageRole { + SYSTEM, + USER, + ASSISTANT; + + @JsonValue + override fun toString(): String { + return name.lowercase() + } +} diff --git a/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/dto/GptResponseFormat.kt b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/dto/GptResponseFormat.kt new file mode 100644 index 0000000..ee7cc52 --- /dev/null +++ b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/llm/openai/dto/GptResponseFormat.kt @@ -0,0 +1,6 @@ +package app.cardcapture.ai.outbound.llm.openai.dto + +data class GptResponseFormat ( + val type: String +){ +} diff --git a/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/prompt/SimplePromptLoaderAdapter.kt b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/prompt/SimplePromptLoaderAdapter.kt new file mode 100644 index 0000000..ef9603e --- /dev/null +++ b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/prompt/SimplePromptLoaderAdapter.kt @@ -0,0 +1,329 @@ +package app.cardcapture.ai.outbound.prompt + +import app.cardcapture.ai.domain.prompt.PromptId +import app.cardcapture.ai.domain.prompt.PromptLoader +import app.cardcapture.ai.domain.prompt.PromptSpec +import org.springframework.stereotype.Component + +/** + * 코드에 하드코딩된 문자열을 Map으로 들고 있는 가장 단순한 로더. + * 이후 file/db 로더로 교체해도 인터페이스 동일. + */ + +@Component +class SimplePromptLoaderAdapter : PromptLoader { + + + + override fun load(id: PromptId): PromptSpec { + val system = registry[id.namespace to id.name] + ?: error("Prompt not found: ${id.namespace}/${id.name}") + return PromptSpec( + system = system, + version = "v0.0", + ) + } + + + + private val metadataPrompt = """ +You are a professional card-news design analyst. +Your job is to analyze the user's input and extract **structured metadata** for use in an automated card-news layout and image generation system. + +The result must be a **strict JSON object** compatible with a Kotlin data model. +Your output will later be converted into: +- background plan (color or image) +- text layers (with font & size) +- image layers (with image generation prompts) + +--- + +## Input +The user message will be provided **as a JSON object** with the following fields: + +```json +{ + "texts": ["string"], + "purpose": "string", + "color": "string", + "userPrompt": "string (optional)" +} + +--- + +## Task + +1. Analyze the input texts and decide: + - role, emphasis, and priority of each text block + - rough placement intention (top, center, bottom, left, right) + - proper font and size based on role/emphasis/mood. + +2. Decide whether the **background** should be a solid color or an image: + - If the user explicitly asks for a background image or clearly implies a visual scene, choose `"IMAGE"`. + - Otherwise, default to `"COLOR"` and use the color/palette. + +3. Decide what **images** (except background) are helpful: + - For each image, decide concept, purpose, importance, and rough placement. + - Also generate an English `prompt` string that can be used directly by an image generation model + (e.g., “a cute illustration of a cat studying at a desk, pastel colors, flat design, 4k”). + +Return **JSON ONLY** with the schema below. + +--- + +## JSON Schema (STRICT) + +```json +{ + "purpose": "string", + "mood": "string", + "style": "string", + "background": { + "mode": "COLOR or IMAGE", + "colorHex": "string (#RRGGBB) or null", + "prompt": "string or null", + "opacity": 0 + }, + "texts": [ + { + "role": "headline | subtitle | body | caption | cta", + "emphasis": "high | medium | low", + "priority": 1, + "text": "string", + "placement": "top-left | top-center | top-right | center-left | center | center-right | bottom-left | bottom-center | bottom-right | null", + "font": "Pretendard | NanumGothic | Jua | NotoSans | NotoSerif | BlackHanSans | DoHyeon | NanumPenScript | GothicA1 | Dongle | Sunflower | Hahmlet | Gugi | Gaegu | GamjaFlower | GowunDodum | GowunBatang | SongMyung | CuteFont | EastSeaDokdo | Stylish | SingleDay | YeonSung | GasoekOne | BagelFatOne | Orbit", + "size": "string like '18px' or '32px' (range 1px–128px)" + } + ], + "images": [ + { + "concept": "short English concept of the image (e.g. 'cute cat icon')", + "prompt": "full English prompt for an image generation model", + "purpose": "main | decorative | icon | background", + "importance": "high | medium | low", + "placement": "top-left | top-right | bottom-left | bottom-right | center | null" + } + ], + "colorScheme": { + "primary": "string (#RRGGBB)", + "secondary": "string (#RRGGBB)", + "text": "string (#RRGGBB)", + "accent": "string (#RRGGBB)", + "reasoning": "string" + }, + "constraints": ["array of strings"] +} + +Background Rules + +If the user clearly mentions or implies a background image (e.g. “배경에 벚꽃 사진 넣어줘”): +background.mode = "IMAGE" +background.prompt = detailed English prompt for the background image +background.colorHex can be null or a supporting color + +Otherwise (default): + +background.mode = "COLOR" + +background.colorHex = use colorScheme.primary or a suitable base color + +background.prompt = null + +background.opacity should be between 0 and 100. +Default to 100 unless a softer/faded background is explicitly better. + +Text Rules + +Always fill font and size: + +Headline: large size (e.g. 32–48px), bold or impactful font (e.g. Gugi, DoHyeon, BlackHanSans) + +Subtitle / body: 16–22px, readable fonts (e.g. Pretendard, NanumGothic, NotoSans) + +CTA: medium size (24–30px), clear and strong (e.g. DoHyeon, GasoekOne) + +Use placement when user intent is clear (e.g. “맨 아래 작게”, “위쪽 가운데 크게”). + +Limit to max 4 text blocks. If there are many sentences, group them logically. + +Image Rules + +For each recommended image (excluding background): + +concept is a short label (e.g. "cat with book icon"). + +prompt is a full, concrete English description for a generative image model. +Include style cues when possible (flat illustration, 3D render, pastel colors, etc.). + +purpose = "main" if it's a key visual, "icon" if small symbolic, "decorative" for supporting visuals. + +If no non-background images are needed, return "images": []. + +Color Rules + +Use purpose, mood, and style to choose a color palette: + +promotional / energetic → brighter primaries and accents + +professional → calmer, more muted palette with strong text contrast + +colorScheme.reasoning should briefly explain the choice. + +Constraints + +Derive any design, tone, or messaging constraints from: + +purpose (e.g., must look trustworthy, must feel playful) + +texts + +user instructions + +If there are no special constraints, return "constraints": []. + +Output + +✅ Output must be valid JSON only (no markdown, no comments). + +✅ All keys must be lowercase and match the schema. + +✅ Use null for unknown scalars and [] + """.trimIndent() + + private val layoutPrompt = """ + +You are a professional card-news layout designer. +You will receive a JSON object describing template design metadata as the **user message**. +Your task is to generate a final layout JSON for a single card (550x550 px). + +The user message will be a JSON with this schema (simplified): + +- purpose: string +- mood: string +- style: string +- background: { mode, colorHex, prompt, opacity } +- texts: array of text blocks with role, emphasis, priority, text, placement, font, size +- images: array of image blocks with concept, purpose, importance, placement +- colorScheme: color palette suggestion +- constraints: list of design/tone constraints + +--- + +## Your Task + +Using that metadata, plan a concrete visual layout: + +1. Decide final background: + - Use `background.mode`, `colorHex`, `prompt`, and `opacity` from metadata. + - If `mode` = `"COLOR"`, the card uses a solid color background. + - If `mode` = `"IMAGE"`, the card will later generate a background image using `prompt`. + +2. For each text you actually place in the card: + - Create a **text layer** with: + - id: unique integer, starting from 0 + - type: "text" + - role: copy from metadata (headline, body, cta, etc.) + - content: the original text (do NOT rewrite) + - font: use the font from metadata (or infer from role if missing) + - size: use the size from metadata (or infer from role/emphasis) + - position: x, y, width, height, rotate, zIndex, opacity + +3. For each image you place: + - Create an **image layer** with: + - id: unique integer + - type: "image" + - concept: short English label for the image (e.g. "cat icon") + - prompt: a full English description that can be sent to an image generation model + (style, mood, composition; e.g. "a cute flat illustration of a cat studying at a desk, pastel colors") + - position: x, y, width, height, rotate, zIndex, opacity + +The image URL will be filled later by another service, so **do not include any URL field**. + +--- + +## Position Rules + +- The card size is exactly 550x550. +- For every layer, position must be: + + - 0 <= x <= 550 + - 0 <= y <= 550 + - width > 0, height > 0 + - Keep at least 24 px margin from all edges when possible. + - Avoid heavy overlaps; slight overlaps are allowed only if visually reasonable. + +- `zIndex`: + - Background is implicit, behind all layers. + - Image layers typically have lower zIndex than text. + - Text layers (headline, CTA) should have the highest zIndex. + +- `opacity`: integer 0–100. + +When metadata includes `placement` hints like "top-center", "bottom-right", etc., +bias the element toward that area but you may shift it by 24–40 px for balance and collision avoidance. + +--- +## Output Format + +Return a single JSON object: + +{ + "background": { + "mode": "COLOR or IMAGE", + "colorHex": "#RRGGBB", + "prompt": "string or null", + "opacity": 100 + }, + "layers": [ + { + "id": 0, + "type": "text", + "role": "headline | subtitle | body | caption | cta", + "content": "string", + "font": "approved font name", + "size": "string like '24px'", + "concept": null, + "prompt": null, + "position": { ... } + }, + { + "id": 1, + "type": "image", + "role": null, + "content": null, + "font": null, + "size": null, + "concept": "short English concept", + "prompt": "full English prompt for image generation", + "position": { ... } + } + ] +} +Rules +✅ Output only JSON. No markdown, no comments, no explanations. + +✅ All keys must match the schema exactly and be lowercase (except "COLOR" / "IMAGE" values). + +✅ Text content must not be rewritten; use exactly the text from metadata. + +✅ Image prompt must be in English and descriptive. + +✅ Do not include any url field. + +✅ Ensure the JSON is strictly valid and can be parsed by a JSON parser. + +- If type = "text": role/content/font/size required, concept/prompt must be null. +- If type = "image": concept/prompt required, role/content/font/size must be null. +- Do NOT include any URL or generate flags. + + """.trimIndent() + + private val registry: Map, String> = mapOf( + // 예시: 템플릿 메타데이터 + ("templateDesign" to "metadata") to metadataPrompt, + + // 예시: 템플릿 레이아웃 스펙 플래너 + ("templateDesign" to "layout") to layoutPrompt, + ) + +} diff --git a/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/s3/ImageStorageAdapter.kt b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/s3/ImageStorageAdapter.kt new file mode 100644 index 0000000..7520080 --- /dev/null +++ b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/s3/ImageStorageAdapter.kt @@ -0,0 +1,36 @@ +package app.cardcapture.ai.outbound.s3 + +import app.cardcapture.ai.port.outbound.ImageStoragePort +import org.springframework.beans.factory.annotation.Value +import org.springframework.stereotype.Component +import software.amazon.awssdk.core.sync.RequestBody +import software.amazon.awssdk.services.s3.S3Client +import software.amazon.awssdk.services.s3.model.PutObjectRequest +import java.util.* + + +@Component +class ImageStorageAdapter( + private val s3Client: S3Client, + @Value("\${cloud.aws.s3.bucket}") private val bucket: String, + @Value("\${cloud.aws.region.static}") private val region: String, +) : ImageStoragePort { + + + override fun upload(bytes: ByteArray, key: String): String { + val safePrefix = key.trimEnd('/') + val objectKey = "$safePrefix/${UUID.randomUUID()}.png" + + + val request = PutObjectRequest.builder() + .bucket(bucket) + .key(objectKey) + .contentType("image/png") + .build() + + s3Client.putObject(request, RequestBody.fromBytes(bytes)) + + + return "https://$bucket.s3.$region.amazonaws.com/$objectKey" + } +} diff --git a/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/s3/S3Config.kt b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/s3/S3Config.kt new file mode 100644 index 0000000..6ac3fa2 --- /dev/null +++ b/platform/ai/adapter/src/main/kotlin/app/cardcapture/ai/outbound/s3/S3Config.kt @@ -0,0 +1,33 @@ +package app.cardcapture.ai.outbound.s3 + +import org.springframework.beans.factory.annotation.Value +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.s3.S3Client + + +@Configuration +class S3Config( + @Value("\${cloud.aws.credentials.access-key}") + private val accessKey: String, + + @Value("\${cloud.aws.credentials.secret-key}") + private val secretKey: String, + + @Value("\${cloud.aws.region.static}") + private val region: String, +) { + + + @Bean + fun amazonS3(): S3Client { + val credentials = AwsBasicCredentials.create(accessKey, secretKey) + return S3Client.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create(credentials)) + .build() + } +} diff --git a/platform/ai/application/build.gradle.kts b/platform/ai/application/build.gradle.kts new file mode 100644 index 0000000..57350a2 --- /dev/null +++ b/platform/ai/application/build.gradle.kts @@ -0,0 +1,21 @@ + +plugins { + kotlin("jvm") + kotlin("plugin.spring") + id("io.spring.dependency-management") +} + + +dependencies { + implementation(project(":ai-domain")) + implementation("org.springframework:spring-context") + implementation("org.springframework:spring-tx") + implementation("org.springframework:spring-aop") + + + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + testImplementation("org.springframework.boot:spring-boot-starter-test") { + exclude(group = "org.mockito") + } + +} diff --git a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/model/TemplateDesignMetadataCommand.kt b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/model/TemplateDesignMetadataCommand.kt new file mode 100644 index 0000000..15c3c0e --- /dev/null +++ b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/model/TemplateDesignMetadataCommand.kt @@ -0,0 +1,9 @@ +package app.cardcapture.ai.model + +data class TemplateDesignMetadataCommand( + val texts: List, + val purpose: String, + val color: String, + val userPrompt: String +) { +} diff --git a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/model/TemplateDesignMetadataResult.kt b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/model/TemplateDesignMetadataResult.kt new file mode 100644 index 0000000..7c781bc --- /dev/null +++ b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/model/TemplateDesignMetadataResult.kt @@ -0,0 +1,45 @@ +package app.cardcapture.ai.model + +data class TemplateDesignMetadataResult( + val purpose: String, + val mood: String, + val style: String, + val background: BackgroundBlock, + val texts: List, + val images: List, + val colorScheme: ColorScheme, + val constraints: List +) { + + data class BackgroundBlock( + val mode: String, + val colorHex: String?, + val prompt: String?, + val opacity: Int + ) + + data class TextBlock( + val role: String, // "headline", "subtitle", "body", "caption", "cta" 등 + val emphasis: String, // "high", "medium", "low" + val priority: Int, + val text: String, + val placement: String, // 선택적 위치 힌트 + val font: String? = null, // Quill 화이트리스트 중 하나 + val size: String? = null // "1px" ~ "128px" + ) + + data class ImageBlock( + val concept: String, + val purpose: String, // "main", "decorative", "icon" 등 + val importance: String, // "high", "medium", "low" + val placement: String// 선택적 위치 힌트 + ) + + data class ColorScheme( + val primary: String, + val secondary: String, + val text: String, + val accent: String, + val reasoning: String + ) +} diff --git a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/model/TemplateLayoutPlanResult.kt b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/model/TemplateLayoutPlanResult.kt new file mode 100644 index 0000000..47cea35 --- /dev/null +++ b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/model/TemplateLayoutPlanResult.kt @@ -0,0 +1,48 @@ +package app.cardcapture.ai.model + +data class TemplateLayoutPlanResult( + val background: LayoutBackground, + val layers: List +) { + + data class LayoutBackground( + val mode: String, // "COLOR" or "IMAGE" + val colorHex: String?, + val prompt: String?, + val opacity: Int + ) + + data class LayoutPosition( + val x: Int, + val y: Int, + val width: Int, + val height: Int, + val rotate: Int, + val zIndex: Int, + val opacity: Int + ) + + /** + * LLM이 주는 레이어를 그대로 받는 단일 DTO + * - type = "text" or "image" + * - text인 경우: role/content/font/size 필수, concept/prompt는 null + * - image인 경우: concept/prompt 필수, role/content/font/size는 null + */ + data class LayoutLayer( + val id: Int, + val type: String, // "text" or "image" + + // text 전용 필드 + val role: String? = null, // "headline" | "body" | ... + val content: String? = null, + val font: String? = null, + val size: String? = null, + + // image 전용 필드 + val concept: String? = null, + val prompt: String? = null, // 이미지 생성용 프롬프트 + + // 공통 + val position: LayoutPosition + ) +} diff --git a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/inbound/AiImageGenerateUseCase.kt b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/inbound/AiImageGenerateUseCase.kt new file mode 100644 index 0000000..566ce2d --- /dev/null +++ b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/inbound/AiImageGenerateUseCase.kt @@ -0,0 +1,8 @@ +package app.cardcapture.ai.port.inbound + +import app.cardcapture.ai.port.inbound.dto.AiImageGenerateCommand + +interface AiImageGenerateUseCase { + + fun generate(command: AiImageGenerateCommand): String +} diff --git a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/inbound/AiTemplateDesignUseCase.kt b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/inbound/AiTemplateDesignUseCase.kt new file mode 100644 index 0000000..abaa78b --- /dev/null +++ b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/inbound/AiTemplateDesignUseCase.kt @@ -0,0 +1,9 @@ +package app.cardcapture.ai.port.inbound + +import app.cardcapture.ai.model.TemplateLayoutPlanResult +import app.cardcapture.ai.port.inbound.dto.AiTemplateDesignCommand + +interface AiTemplateDesignUseCase { + + fun design(command: AiTemplateDesignCommand): TemplateLayoutPlanResult +} diff --git a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/inbound/dto/AiImageGenerateCommand.kt b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/inbound/dto/AiImageGenerateCommand.kt new file mode 100644 index 0000000..d6452ad --- /dev/null +++ b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/inbound/dto/AiImageGenerateCommand.kt @@ -0,0 +1,21 @@ +package app.cardcapture.ai.port.inbound.dto + +import app.cardcapture.ai.domain.ImageModel + +data class AiImageGenerateCommand( + val prompt: String, + val model: ImageModel +) { + + companion object { + fun ofOrThrow( + prompt: String, + model: String + ): AiImageGenerateCommand { + return AiImageGenerateCommand( + prompt = prompt, + model = ImageModel.from(model), + ) + } + } +} diff --git a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/inbound/dto/AiTemplateDesignCommand.kt b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/inbound/dto/AiTemplateDesignCommand.kt new file mode 100644 index 0000000..38fae63 --- /dev/null +++ b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/inbound/dto/AiTemplateDesignCommand.kt @@ -0,0 +1,36 @@ +package app.cardcapture.ai.port.inbound.dto + +import app.cardcapture.ai.domain.ImageModel +import app.cardcapture.ai.domain.LlmModel + +data class AiTemplateDesignCommand private constructor( + val llmModel: LlmModel, + val imageModel: ImageModel, + val purpose: String, + val texts: List, + val color: String, + val prompt: String, +) { + + companion object { + + fun ofOrThrow( + llmModel: String, + imageModel: String, + purpose: String, + texts: List, + color : String, + prompt: String + ): AiTemplateDesignCommand { + + return AiTemplateDesignCommand( + llmModel = LlmModel.from(llmModel), + imageModel = ImageModel.from(imageModel), + purpose = purpose, + texts = texts, + color = color, + prompt = prompt, + ) + } + } +} diff --git a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/outbound/ImageModelPort.kt b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/outbound/ImageModelPort.kt new file mode 100644 index 0000000..1af32ee --- /dev/null +++ b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/outbound/ImageModelPort.kt @@ -0,0 +1,7 @@ +package app.cardcapture.ai.port.outbound + +interface ImageModelPort { + + fun generate(prompt: String, model: String) : ByteArray + +} diff --git a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/outbound/ImageStoragePort.kt b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/outbound/ImageStoragePort.kt new file mode 100644 index 0000000..db694a7 --- /dev/null +++ b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/outbound/ImageStoragePort.kt @@ -0,0 +1,6 @@ +package app.cardcapture.ai.port.outbound + +interface ImageStoragePort { + + fun upload(bytes: ByteArray, key: String): String +} diff --git a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/outbound/LlmSenderPort.kt b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/outbound/LlmSenderPort.kt new file mode 100644 index 0000000..7698c5a --- /dev/null +++ b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/port/outbound/LlmSenderPort.kt @@ -0,0 +1,9 @@ +package app.cardcapture.ai.port.outbound + +import app.cardcapture.ai.domain.LlmRequest +import app.cardcapture.ai.domain.LlmResponse + +interface LlmSenderPort { + + fun send(request: LlmRequest): LlmResponse +} diff --git a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/AiImageGenerateService.kt b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/AiImageGenerateService.kt new file mode 100644 index 0000000..c7cf1e1 --- /dev/null +++ b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/AiImageGenerateService.kt @@ -0,0 +1,19 @@ +package app.cardcapture.ai.service + +import app.cardcapture.ai.port.inbound.AiImageGenerateUseCase +import app.cardcapture.ai.port.inbound.dto.AiImageGenerateCommand +import app.cardcapture.ai.port.outbound.ImageModelPort +import app.cardcapture.ai.port.outbound.ImageStoragePort +import org.springframework.stereotype.Service + +@Service +class AiImageGenerateService( + private val imageModelPort: ImageModelPort, + private val imageStoragePort: ImageStoragePort +): AiImageGenerateUseCase { + + override fun generate(command: AiImageGenerateCommand): String { + val byteArrays = imageModelPort.generate(command.prompt, command.model.name) + return imageStoragePort.upload(byteArrays, "generate") + } +} diff --git a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/AiTemplateDesignService.kt b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/AiTemplateDesignService.kt new file mode 100644 index 0000000..23ccb97 --- /dev/null +++ b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/AiTemplateDesignService.kt @@ -0,0 +1,20 @@ +package app.cardcapture.ai.service + +import app.cardcapture.ai.model.TemplateLayoutPlanResult +import app.cardcapture.ai.port.inbound.AiTemplateDesignUseCase +import app.cardcapture.ai.port.inbound.dto.AiTemplateDesignCommand +import org.springframework.stereotype.Service + +@Service +class AiTemplateDesignService( + private val metadataExtractor: MetadataExtractor, + private val layoutPlanner: LayoutPlanner +) : AiTemplateDesignUseCase { + + override fun design(command: AiTemplateDesignCommand): TemplateLayoutPlanResult { + val metadata = metadataExtractor.extract(command) + + val result = layoutPlanner.plan(metadata, command.llmModel) + return result + } +} diff --git a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/LayoutPlanner.kt b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/LayoutPlanner.kt new file mode 100644 index 0000000..15c9127 --- /dev/null +++ b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/LayoutPlanner.kt @@ -0,0 +1,48 @@ +package app.cardcapture.ai.service + +import app.cardcapture.ai.domain.LlmModel +import app.cardcapture.ai.domain.LlmRequest +import app.cardcapture.ai.domain.prompt.PromptId +import app.cardcapture.ai.domain.prompt.PromptLoader +import app.cardcapture.ai.model.TemplateDesignMetadataResult +import app.cardcapture.ai.model.TemplateLayoutPlanResult +import app.cardcapture.ai.port.outbound.LlmSenderPort +import com.fasterxml.jackson.core.JsonProcessingException +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import org.springframework.stereotype.Component + +@Component +class LayoutPlanner( + private val promptLoader: PromptLoader, + private val llmSenderPort: LlmSenderPort, + private val objectMapper: ObjectMapper +) { + + private val namespace: String = "templateDesign" + private val name: String = "layout" + + fun plan(metadata: TemplateDesignMetadataResult, model: LlmModel): TemplateLayoutPlanResult { + val prompt = promptLoader.load( + PromptId( + namespace, + name, + ), + ) + + val request = LlmRequest( + model = model, + system = prompt.system, + user = objectMapper.writeValueAsString(metadata), + ) + + + val response = llmSenderPort.send(request) + + try { + return objectMapper.readValue(response.jsonValueRaw) + } catch (e: JsonProcessingException) { + throw IllegalStateException("fail to llm send result") + } + } +} diff --git a/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/MetadataExtractor.kt b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/MetadataExtractor.kt new file mode 100644 index 0000000..0b3aa83 --- /dev/null +++ b/platform/ai/application/src/main/kotlin/app/cardcapture/ai/service/MetadataExtractor.kt @@ -0,0 +1,53 @@ +package app.cardcapture.ai.service + +import app.cardcapture.ai.domain.LlmRequest +import app.cardcapture.ai.domain.prompt.PromptId +import app.cardcapture.ai.domain.prompt.PromptLoader +import app.cardcapture.ai.model.TemplateDesignMetadataCommand +import app.cardcapture.ai.model.TemplateDesignMetadataResult +import app.cardcapture.ai.port.inbound.dto.AiTemplateDesignCommand +import app.cardcapture.ai.port.outbound.LlmSenderPort +import com.fasterxml.jackson.core.JsonProcessingException +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import org.springframework.stereotype.Component + +@Component +class MetadataExtractor( + private val promptLoader: PromptLoader, + private val objectMapper: ObjectMapper, + private val llmSenderPort: LlmSenderPort +) { + + private val namespace: String = "templateDesign" + private val name: String = "metadata" + + fun extract(aiTemplateDesignCommand: AiTemplateDesignCommand): TemplateDesignMetadataResult { + + val prompt = promptLoader.load( + PromptId( + namespace, + name, + ), + ) + + val content = TemplateDesignMetadataCommand( + texts = aiTemplateDesignCommand.texts, + purpose = aiTemplateDesignCommand.purpose, + color = aiTemplateDesignCommand.color, + userPrompt = aiTemplateDesignCommand.prompt + ) + + val request = LlmRequest( + model = aiTemplateDesignCommand.llmModel, + system = prompt.system, + user = objectMapper.writeValueAsString(content), + ) + val response = llmSenderPort.send(request) + try { + return objectMapper.readValue(response.jsonValueRaw) + } catch (e: JsonProcessingException) { + throw IllegalStateException("fail to llm send result") + } + } +} diff --git a/platform/ai/domain/build.gradle.kts b/platform/ai/domain/build.gradle.kts new file mode 100644 index 0000000..9f57929 --- /dev/null +++ b/platform/ai/domain/build.gradle.kts @@ -0,0 +1,11 @@ +plugins { + kotlin("jvm") +} + +kotlin { + jvmToolchain(21) +} + +dependencies { + // 순수 도메인 +} diff --git a/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/ImageModel.kt b/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/ImageModel.kt new file mode 100644 index 0000000..dd5a9dc --- /dev/null +++ b/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/ImageModel.kt @@ -0,0 +1,11 @@ +package app.cardcapture.ai.domain + +enum class ImageModel { + NANOBANANA; + + companion object { + fun from(name: String): ImageModel = + ImageModel.entries.find { it.name.equals(name, ignoreCase = true) } + ?: throw IllegalArgumentException("Unsupported imageModel: $name") + } +} diff --git a/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/LlmModel.kt b/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/LlmModel.kt new file mode 100644 index 0000000..a9c25e2 --- /dev/null +++ b/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/LlmModel.kt @@ -0,0 +1,11 @@ +package app.cardcapture.ai.domain + +enum class LlmModel(val model : String) { + GPT("gpt-4o"), GEMINI("gemini-2.5-preview"); + + companion object { + fun from(name: String): LlmModel = + entries.find { it.name.equals(name, ignoreCase = true) } + ?: throw IllegalArgumentException("Unsupported LlmModel: $name") + } +} diff --git a/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/LlmRequest.kt b/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/LlmRequest.kt new file mode 100644 index 0000000..5f04f90 --- /dev/null +++ b/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/LlmRequest.kt @@ -0,0 +1,8 @@ +package app.cardcapture.ai.domain + +data class LlmRequest ( + val model : LlmModel, + val system: String, + val user : String +) { +} diff --git a/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/LlmResponse.kt b/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/LlmResponse.kt new file mode 100644 index 0000000..0a36439 --- /dev/null +++ b/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/LlmResponse.kt @@ -0,0 +1,8 @@ +package app.cardcapture.ai.domain + +data class LlmResponse( + val jsonValueRaw: String, + val resourceUsage: String? +) { + +} diff --git a/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/prompt/PromptId.kt b/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/prompt/PromptId.kt new file mode 100644 index 0000000..4374c02 --- /dev/null +++ b/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/prompt/PromptId.kt @@ -0,0 +1,7 @@ +package app.cardcapture.ai.domain.prompt + +data class PromptId( + val namespace: String, + val name : String, +) { +} diff --git a/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/prompt/PromptLoader.kt b/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/prompt/PromptLoader.kt new file mode 100644 index 0000000..0445a82 --- /dev/null +++ b/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/prompt/PromptLoader.kt @@ -0,0 +1,6 @@ +package app.cardcapture.ai.domain.prompt + +interface PromptLoader { + + fun load(id: PromptId): PromptSpec +} diff --git a/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/prompt/PromptSpec.kt b/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/prompt/PromptSpec.kt new file mode 100644 index 0000000..06b9314 --- /dev/null +++ b/platform/ai/domain/src/main/kotlin/app/cardcapture/ai/domain/prompt/PromptSpec.kt @@ -0,0 +1,7 @@ +package app.cardcapture.ai.domain.prompt + +data class PromptSpec( + val version: String, + val system: String, +) { +} diff --git a/platform/auth/application/src/test/kotlin/app/cardcapture/auth/application/service/DevelopmentLoginServiceTest.kt b/platform/auth/application/src/test/kotlin/app/cardcapture/auth/application/service/DevelopmentLoginServiceTest.kt index 6838058..cf07387 100644 --- a/platform/auth/application/src/test/kotlin/app/cardcapture/auth/application/service/DevelopmentLoginServiceTest.kt +++ b/platform/auth/application/src/test/kotlin/app/cardcapture/auth/application/service/DevelopmentLoginServiceTest.kt @@ -33,7 +33,7 @@ class DevelopmentLoginServiceTest( assertThat(token).isEqualTo("token") verify { - loadMemberPort.loadMemberByOAuth(oauthId, OAuthProvider.GOOGLE.name) + loadMemberPort.loadMemberByOAuth(oauthId, OAuthProvider.GOOGLE) issueTokenPort.issue(member) } } diff --git a/platform/services/api/build.gradle.kts b/platform/services/api/build.gradle.kts index 5a3e368..be7ed33 100644 --- a/platform/services/api/build.gradle.kts +++ b/platform/services/api/build.gradle.kts @@ -10,6 +10,8 @@ dependencies { implementation(project(":security")) implementation(project(":auth-adapter")) implementation(project(":member-adapter")) + implementation(project(":ai-adapter")) + implementation(project(":template-adapter")) implementation("org.springframework.boot:spring-boot-starter-web") diff --git a/platform/services/api/src/main/resources/application.yml b/platform/services/api/src/main/resources/application.yml index e7fd033..baff0fd 100644 --- a/platform/services/api/src/main/resources/application.yml +++ b/platform/services/api/src/main/resources/application.yml @@ -17,6 +17,22 @@ auth: secret-key: ${JWT_SECRET} access-token-expiration: 1h +openai: + key: ${OPENAI_KEY} + +google: + key: ${GOOGLE_KEY} + +cloud: + aws: + s3: + bucket: ${S3_BUCKET} + credentials: + access-key: ${S3_ACCESS_KEY} + secret-key: ${S3_SECRET_KEY} + region: + static: ${S3_REGION} + --- spring: diff --git a/platform/template/adapter/build.gradle.kts b/platform/template/adapter/build.gradle.kts new file mode 100644 index 0000000..f149f90 --- /dev/null +++ b/platform/template/adapter/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + kotlin("jvm") + kotlin("plugin.spring") + kotlin("plugin.jpa") + id("io.spring.dependency-management") +} + +dependencies { + + implementation(project(":template-domain")) + implementation(project(":template-application")) + implementation(project(":ai-application")) + + implementation(project(":security")) + + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("org.jetbrains.kotlin:kotlin-reflect") + + // --- test --- + testImplementation("org.springframework.boot:spring-boot-starter-test") { + exclude(group = "org.mockito") + } + testImplementation("com.ninja-squad:springmockk:latest.release") + +} diff --git a/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/TemplateController.kt b/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/TemplateController.kt new file mode 100644 index 0000000..836a6de --- /dev/null +++ b/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/TemplateController.kt @@ -0,0 +1,23 @@ +package app.cardcapture.template.adapter.inbound.web + +import app.cardcapture.template.adapter.inbound.web.dto.GenerateCardTemplateRequest +import app.cardcapture.template.adapter.inbound.web.dto.GenerateCardTemplateResponse +import app.cardcapture.template.application.port.inbound.GenerateCardTemplateUseCase +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RestController + +@RestController +class TemplateController( + private val generateCardTemplateUseCase: GenerateCardTemplateUseCase +) { + + @PostMapping("/api/template") + fun generate(@RequestBody request: GenerateCardTemplateRequest): ResponseEntity { + + val result = generateCardTemplateUseCase.generate(request.toCommand()) + + return ResponseEntity.ok(GenerateCardTemplateResponse.from(result)) + } +} diff --git a/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/dto/GenerateCardTemplateRequest.kt b/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/dto/GenerateCardTemplateRequest.kt new file mode 100644 index 0000000..915a196 --- /dev/null +++ b/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/dto/GenerateCardTemplateRequest.kt @@ -0,0 +1,22 @@ +package app.cardcapture.template.adapter.inbound.web.dto + +import app.cardcapture.template.application.port.inbound.dto.GenerateCardTemplateCommand + +data class GenerateCardTemplateRequest( + val purpose: String, + val texts: List, + val color: String, + val model: String, + val prompt: String +) { + + fun toCommand() : GenerateCardTemplateCommand{ + return GenerateCardTemplateCommand( + purpose = purpose, + texts = texts, + color = color, + model = model, + prompt = prompt + ) + } +} diff --git a/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/dto/GenerateCardTemplateResponse.kt b/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/dto/GenerateCardTemplateResponse.kt new file mode 100644 index 0000000..f6b379c --- /dev/null +++ b/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/inbound/web/dto/GenerateCardTemplateResponse.kt @@ -0,0 +1,40 @@ +package app.cardcapture.template.adapter.inbound.web.dto + +import app.cardcapture.template.domain.Template + + +data class GenerateCardTemplateResponse( + val id: Long, + val userId: Long, + val title: String, + val description: String, + val likes: Int, // TODO + val purchaseCount: Int, // TODO + val editor: String, + val fileUrl: String?, + val templateTags: List, + + ) { + + data class TemplateTagsResponse( + val english: String, + val korean: String, + ) + + companion object { + + fun from(template: Template): GenerateCardTemplateResponse { + return GenerateCardTemplateResponse( + id = template.id, + userId = template.userId, + title = template.title, + description = template.description, + likes = 0, + purchaseCount = 0, + editor = template.editorPayload.editor, + fileUrl = template.fileUrl, + templateTags = listOf(), + ) + } + } +} diff --git a/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/outbound/AiTemplateDesignAdapter.kt b/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/outbound/AiTemplateDesignAdapter.kt new file mode 100644 index 0000000..ba9720b --- /dev/null +++ b/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/outbound/AiTemplateDesignAdapter.kt @@ -0,0 +1,77 @@ +package app.cardcapture.template.adapter.outbound + +import app.cardcapture.ai.model.TemplateLayoutPlanResult +import app.cardcapture.ai.port.inbound.AiTemplateDesignUseCase +import app.cardcapture.ai.port.inbound.dto.AiTemplateDesignCommand +import app.cardcapture.template.application.model.* +import app.cardcapture.template.application.port.outbound.AiTemplateDesignPort +import app.cardcapture.template.application.port.outbound.dto.AiTemplateDesignPortCommand +import org.springframework.stereotype.Component + + +@Component +class AiTemplateDesignAdapter( + private val aiTemplateDesignUseCase: AiTemplateDesignUseCase +) : AiTemplateDesignPort { + + override fun design(prompt: AiTemplateDesignPortCommand): AiTemplateDesignResult { + val design: TemplateLayoutPlanResult = aiTemplateDesignUseCase.design( + AiTemplateDesignCommand.ofOrThrow( + llmModel = prompt.llmModel, + imageModel = prompt.model, + purpose = prompt.purpose, + texts = prompt.texts, + prompt = prompt.prompt, + color = prompt.color + ) + ) + + return design.toTemplateResult() + } + + private fun TemplateLayoutPlanResult.toTemplateResult(): AiTemplateDesignResult { + val bg = when (background.mode.uppercase()) { + "IMAGE" -> BackgroundPlanDto.image( + prompt = requireNotNull(background.prompt), + opacity = background.opacity + ) + + "COLOR" -> BackgroundPlanDto.color( + colorHex = requireNotNull(background.colorHex) { "COLOR mode background colorHex is null" }, + opacity = background.opacity + ) + + else -> error("Unknown background mode: ${background.mode}") + } + + val layers = layers.map { layer -> + when (layer.type.lowercase()) { + "text" -> PlannedTextLayerDto( + id = layer.id, + position = layer.position.toDto(), + text = requireNotNull(layer.content) { "text layer content is null" }, + font = requireNotNull(layer.font) { "text layer font is null" }, + size = requireNotNull(layer.size) { "text layer size is null" } + ) + + "image" -> PlannedImageLayerDto( + id = layer.id, + position = layer.position.toDto(), + url = null, + generate = true, + prompt = requireNotNull(layer.prompt) { "image layer prompt is null" } + ) + + else -> error("Unknown layer type: ${layer.type}") + } + } + + return AiTemplateDesignResult( + background = bg, + layers = layers + ) + } + + private fun TemplateLayoutPlanResult.LayoutPosition.toDto(): PositionDto = + PositionDto(x, y, width, height, rotate, zIndex, opacity) +} diff --git a/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/outbound/GeneratedImageAdapter.kt b/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/outbound/GeneratedImageAdapter.kt new file mode 100644 index 0000000..8bbed7b --- /dev/null +++ b/platform/template/adapter/src/main/kotlin/app/cardcapture/template/adapter/outbound/GeneratedImageAdapter.kt @@ -0,0 +1,23 @@ +package app.cardcapture.template.adapter.outbound + +import app.cardcapture.ai.port.inbound.AiImageGenerateUseCase +import app.cardcapture.ai.port.inbound.dto.AiImageGenerateCommand +import app.cardcapture.template.application.port.outbound.GenerateImagePort +import app.cardcapture.template.application.port.outbound.dto.GenerateImagePortCommand +import org.springframework.stereotype.Component + +@Component +class GeneratedImageAdapter( + private val aiImageGenerateUseCase: AiImageGenerateUseCase +) : GenerateImagePort { + + override fun generate(command: GenerateImagePortCommand): String { + + return aiImageGenerateUseCase.generate( + AiImageGenerateCommand.ofOrThrow( + prompt = command.prompt, + model = command.model, + ), + ) + } +} diff --git a/platform/template/application/build.gradle.kts b/platform/template/application/build.gradle.kts new file mode 100644 index 0000000..9bd9464 --- /dev/null +++ b/platform/template/application/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + kotlin("jvm") + kotlin("plugin.spring") + id("io.spring.dependency-management") +} + + +dependencies { + implementation(project(":template-domain")) + implementation("org.springframework:spring-context") + implementation("org.springframework:spring-tx") + implementation("org.springframework:spring-aop") + + + testImplementation("org.springframework.boot:spring-boot-starter-test") { + exclude(group = "org.mockito") + } + +} diff --git a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/AiTemplateDesignResult.kt b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/AiTemplateDesignResult.kt new file mode 100644 index 0000000..ccade3f --- /dev/null +++ b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/AiTemplateDesignResult.kt @@ -0,0 +1,8 @@ +package app.cardcapture.template.application.model + +data class AiTemplateDesignResult( + val background : BackgroundPlanDto, + val layers: List +){ + +} diff --git a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/BackgroundModeDto.kt b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/BackgroundModeDto.kt new file mode 100644 index 0000000..227e19b --- /dev/null +++ b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/BackgroundModeDto.kt @@ -0,0 +1,5 @@ +package app.cardcapture.template.application.model + +enum class BackgroundModeDto { + COLOR, IMAGE +} diff --git a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/BackgroundPlanDto.kt b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/BackgroundPlanDto.kt new file mode 100644 index 0000000..f2fd823 --- /dev/null +++ b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/BackgroundPlanDto.kt @@ -0,0 +1,43 @@ +package app.cardcapture.template.application.model + +data class BackgroundPlanDto private constructor( + val mode: BackgroundModeDto, + val colorHex: String? = null, + val url: String? = null, + val prompt: String? = null, + val opacity: Int = 100 +) { + companion object { + fun color(colorHex: String, opacity: Int = 100): BackgroundPlanDto { + require(colorHex.isNotBlank()) { "colorHex must not be blank for COLOR mode" } + return BackgroundPlanDto( + mode = BackgroundModeDto.COLOR, + colorHex = colorHex, + url = null, + prompt = null, + opacity = opacity + ) + } + + fun image( prompt: String, opacity: Int = 100): BackgroundPlanDto { + require(prompt.isNotBlank()) { "prompt must not be blank for IMAGE mode" } + return BackgroundPlanDto( + mode = BackgroundModeDto.IMAGE, + colorHex = null, + prompt = prompt, + opacity = opacity + ) + } + } + + init { + when (mode) { + BackgroundModeDto.COLOR -> require(url == null && prompt == null) { + "For COLOR mode, url and prompt must be null" + } + BackgroundModeDto.IMAGE -> require(colorHex == null) { + "For IMAGE mode, colorHex must be null" + } + } + } +} diff --git a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/PlannedImageLayerDto.kt b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/PlannedImageLayerDto.kt new file mode 100644 index 0000000..e9e6098 --- /dev/null +++ b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/PlannedImageLayerDto.kt @@ -0,0 +1,10 @@ +package app.cardcapture.template.application.model + +data class PlannedImageLayerDto( + override val id: Int, + override val position: PositionDto, + val url: String? = null, + val generate: Boolean = false, + val prompt: String +) : PlannedLayerDto { +} diff --git a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/PlannedLayerDto.kt b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/PlannedLayerDto.kt new file mode 100644 index 0000000..316ff81 --- /dev/null +++ b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/PlannedLayerDto.kt @@ -0,0 +1,7 @@ +package app.cardcapture.template.application.model + + +sealed interface PlannedLayerDto { + val id : Int + val position: PositionDto +} diff --git a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/PlannedTextLayerDto.kt b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/PlannedTextLayerDto.kt new file mode 100644 index 0000000..cce8175 --- /dev/null +++ b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/PlannedTextLayerDto.kt @@ -0,0 +1,10 @@ +package app.cardcapture.template.application.model + +data class PlannedTextLayerDto( + override val id: Int, + override val position: PositionDto, + val text: String, + val font: String, + val size: String +) : PlannedLayerDto { +} diff --git a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/PositionDto.kt b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/PositionDto.kt new file mode 100644 index 0000000..e31898c --- /dev/null +++ b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/model/PositionDto.kt @@ -0,0 +1,12 @@ +package app.cardcapture.template.application.model + +data class PositionDto( + val x: Int, + val y: Int, + val width: Int, + val height: Int, + val rotate: Int = 0, + val zIndex: Int = 0, + val opacity: Int = 100 +) + diff --git a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/inbound/GenerateCardTemplateUseCase.kt b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/inbound/GenerateCardTemplateUseCase.kt new file mode 100644 index 0000000..448a292 --- /dev/null +++ b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/inbound/GenerateCardTemplateUseCase.kt @@ -0,0 +1,9 @@ +package app.cardcapture.template.application.port.inbound + +import app.cardcapture.template.application.port.inbound.dto.GenerateCardTemplateCommand +import app.cardcapture.template.domain.Template + +interface GenerateCardTemplateUseCase { + + fun generate(command : GenerateCardTemplateCommand): Template +} diff --git a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/inbound/dto/GenerateCardTemplateCommand.kt b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/inbound/dto/GenerateCardTemplateCommand.kt new file mode 100644 index 0000000..1ef5985 --- /dev/null +++ b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/inbound/dto/GenerateCardTemplateCommand.kt @@ -0,0 +1,10 @@ +package app.cardcapture.template.application.port.inbound.dto + +data class GenerateCardTemplateCommand ( + val purpose: String, + val texts: List, + val color: String, + val model: String, + val prompt: String, +){ +} diff --git a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/outbound/AiTemplateDesignPort.kt b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/outbound/AiTemplateDesignPort.kt new file mode 100644 index 0000000..f4f6dfa --- /dev/null +++ b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/outbound/AiTemplateDesignPort.kt @@ -0,0 +1,9 @@ +package app.cardcapture.template.application.port.outbound + +import app.cardcapture.template.application.model.AiTemplateDesignResult +import app.cardcapture.template.application.port.outbound.dto.AiTemplateDesignPortCommand + +interface AiTemplateDesignPort { + + fun design(prompt: AiTemplateDesignPortCommand): AiTemplateDesignResult +} diff --git a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/outbound/GenerateImagePort.kt b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/outbound/GenerateImagePort.kt new file mode 100644 index 0000000..e152bb5 --- /dev/null +++ b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/outbound/GenerateImagePort.kt @@ -0,0 +1,7 @@ +package app.cardcapture.template.application.port.outbound + +import app.cardcapture.template.application.port.outbound.dto.GenerateImagePortCommand + +interface GenerateImagePort { + fun generate(command: GenerateImagePortCommand): String +} diff --git a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/outbound/dto/AiTemplateDesignPortCommand.kt b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/outbound/dto/AiTemplateDesignPortCommand.kt new file mode 100644 index 0000000..7b79df1 --- /dev/null +++ b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/outbound/dto/AiTemplateDesignPortCommand.kt @@ -0,0 +1,11 @@ +package app.cardcapture.template.application.port.outbound.dto + +data class AiTemplateDesignPortCommand( + val purpose: String, + val texts: List, + val color: String, + val prompt: String, + val model : String, + val llmModel: String, +) { +} diff --git a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/outbound/dto/GenerateImagePortCommand.kt b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/outbound/dto/GenerateImagePortCommand.kt new file mode 100644 index 0000000..54ca53a --- /dev/null +++ b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/port/outbound/dto/GenerateImagePortCommand.kt @@ -0,0 +1,7 @@ +package app.cardcapture.template.application.port.outbound.dto + +data class GenerateImagePortCommand( + val prompt: String, + val model: String +) { +} diff --git a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/service/EditorJsonConverter.kt b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/service/EditorJsonConverter.kt new file mode 100644 index 0000000..d9eeb0d --- /dev/null +++ b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/service/EditorJsonConverter.kt @@ -0,0 +1,99 @@ +package app.cardcapture.template.application.service + +import app.cardcapture.template.application.model.* +import org.springframework.stereotype.Component + +@Component +class EditorJsonConverter( +) { + + fun toJson(plan: AiTemplateDesignResult): String { + val backgroundUrl = + if (plan.background.mode == BackgroundModeDto.IMAGE) + plan.background.url.orEmpty() + else + "" + + val bgColor = plan.background.colorHex ?: "" + + + + val layersJson = plan.layers.joinToString(",") { layer -> + when (layer) { + is PlannedTextLayerDto -> toTextLayerJson(layer) + is PlannedImageLayerDto -> toImageLayerJson(layer) + } + } + + return """ + [ + { + "id": 0, + "background": { + "url": "$backgroundUrl", + "opacity": ${plan.background.opacity}, + "color": "$bgColor" + }, + "layers": [ $layersJson ] + } + ] + """.trimIndent() + } + + private fun toTextLayerJson(layer: PlannedTextLayerDto): String { + val text = escapeJson(layer.text) + val font = escapeJson(layer.font) // PlannedTextLayerDto에 font: String 존재한다고 가정 + val size = escapeJson(layer.size) // size: String ("32px" 등) + + return """ + { + "type": "text", + "id": ${layer.id}, + "position": ${toPositionJson(layer.position)}, + "content": { + "content": { + "ops": [ + { + "insert": "$text", + "attributes": { + "font": "$font", + "size": "$size" + } + }, + { + "insert": "\n" + } + ] + } + } + } + """.trimIndent() + } + + private fun toImageLayerJson(layer: PlannedImageLayerDto): String { + val url = layer.url.orEmpty() + + return """ + { + "type": "image", + "id": ${layer.id}, + "position": ${toPositionJson(layer.position)}, + "content": { + "url": "$url" + } + } + """.trimIndent() + } + + private fun toPositionJson(pos: PositionDto): String = + """{"x":${pos.x},"y":${pos.y},"width":${pos.width},"height":${pos.height}, + "rotate":${pos.rotate},"zIndex":${pos.zIndex},"opacity":${pos.opacity}}""" + + private fun escapeJson(text: String): String = + text.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + +} diff --git a/platform/template/application/src/main/kotlin/app/cardcapture/template/application/service/GenerateCardTemplateService.kt b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/service/GenerateCardTemplateService.kt new file mode 100644 index 0000000..204f6e7 --- /dev/null +++ b/platform/template/application/src/main/kotlin/app/cardcapture/template/application/service/GenerateCardTemplateService.kt @@ -0,0 +1,93 @@ +package app.cardcapture.template.application.service + +import app.cardcapture.template.application.model.* +import app.cardcapture.template.application.port.inbound.GenerateCardTemplateUseCase +import app.cardcapture.template.application.port.inbound.dto.GenerateCardTemplateCommand +import app.cardcapture.template.application.port.outbound.AiTemplateDesignPort +import app.cardcapture.template.application.port.outbound.GenerateImagePort +import app.cardcapture.template.application.port.outbound.dto.AiTemplateDesignPortCommand +import app.cardcapture.template.application.port.outbound.dto.GenerateImagePortCommand +import app.cardcapture.template.domain.EditorPayload +import app.cardcapture.template.domain.Template +import org.springframework.stereotype.Service + +@Service +class GenerateCardTemplateService( + private val aiTemplateDesignPort: AiTemplateDesignPort, + private val generateImagePort: GenerateImagePort, + private val editorJsonConverter: EditorJsonConverter +) : GenerateCardTemplateUseCase { + + override fun generate(command: GenerateCardTemplateCommand): Template { + + val planResult = aiTemplateDesignPort.design( + AiTemplateDesignPortCommand( + purpose = command.purpose, + texts = command.texts, + color = command.color, + prompt = command.prompt, + model = command.model, + llmModel = "GPT" // TODO 현재 기획상 단일 LLM 사용 + ), + ) + + val checkedPlanResult = planResult.copy( + background = backgroundImageCheck(planResult.background, command.model), + layers = layerImageCheck(planResult.layers, command.model), + ) + + + return Template( + id = 1L, // TODO: auto increment, + userId = 1L, // TODO: security + purpose = command.purpose, + prompt = command.prompt, + color = command.color, + title = command.purpose, + description = command.texts.firstOrNull() ?: "" , + texts = command.texts, + fileUrl = null, + editorPayload = EditorPayload(editorJsonConverter.toJson(checkedPlanResult)) + ) + } + + private fun backgroundImageCheck(backgroundPlan: BackgroundPlanDto, model: String): BackgroundPlanDto { + if (backgroundPlan.mode == BackgroundModeDto.COLOR) { + return backgroundPlan + } + + val prompt = backgroundPlan.prompt ?: error("background image prompt가 필요합니다. ") + + val backGroundImageUrl = generateImagePort.generate( + GenerateImagePortCommand( + prompt = prompt, + model = model, + ), + ) + return backgroundPlan.copy( + url = backGroundImageUrl, + ) + } + + private fun layerImageCheck(layers: List, model: String): List { + return layers.map { layer -> + when (layer) { + is PlannedTextLayerDto -> layer + is PlannedImageLayerDto -> { + if (!layer.generate) return@map layer + val prompt = layer.prompt + val img = generateImagePort.generate( + GenerateImagePortCommand( + prompt = prompt, + model = model, + ), + ) + layer.copy( + url = img, + generate = false, + ) + } + } + } + } +} diff --git a/platform/template/domain/build.gradle.kts b/platform/template/domain/build.gradle.kts new file mode 100644 index 0000000..9f57929 --- /dev/null +++ b/platform/template/domain/build.gradle.kts @@ -0,0 +1,11 @@ +plugins { + kotlin("jvm") +} + +kotlin { + jvmToolchain(21) +} + +dependencies { + // 순수 도메인 +} diff --git a/platform/template/domain/src/main/kotlin/app/cardcapture/template/domain/EditorPayload.kt b/platform/template/domain/src/main/kotlin/app/cardcapture/template/domain/EditorPayload.kt new file mode 100644 index 0000000..c220383 --- /dev/null +++ b/platform/template/domain/src/main/kotlin/app/cardcapture/template/domain/EditorPayload.kt @@ -0,0 +1,7 @@ +package app.cardcapture.template.domain + +data class EditorPayload( + val editor: String +){ + +} diff --git a/platform/template/domain/src/main/kotlin/app/cardcapture/template/domain/Template.kt b/platform/template/domain/src/main/kotlin/app/cardcapture/template/domain/Template.kt new file mode 100644 index 0000000..3d3fb00 --- /dev/null +++ b/platform/template/domain/src/main/kotlin/app/cardcapture/template/domain/Template.kt @@ -0,0 +1,22 @@ +package app.cardcapture.template.domain + +/* +* title, description 값을 따로 FE로 받지 않는다. +* title -> purpose, +* description -> texts +* purpose, texts, prompt, color 는 추후 table 분리 가능성. +*/ + +class Template( + val id : Long, + val userId: Long, + val title: String, + val description: String, + val prompt: String, + val fileUrl: String?, + val purpose: String, + val texts: List, + val color: String, + val editorPayload: EditorPayload +) { +} diff --git a/settings.gradle.kts b/settings.gradle.kts index af22f43..7571d4a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -10,6 +10,12 @@ include( "member-domain", "member-application", "member-adapter", + "ai-domain", + "ai-application", + "ai-adapter", + "template-domain", + "template-application", + "template-adapter", "platform-api") project(":auth-domain").projectDir = file("platform/auth/domain") @@ -18,6 +24,12 @@ project(":auth-adapter").projectDir = file("platform/auth/adapter") project(":member-domain").projectDir = file("platform/member/domain") project(":member-application").projectDir = file("platform/member/application") project(":member-adapter").projectDir = file("platform/member/adapter") +project(":ai-domain").projectDir = file("platform/ai/domain") +project(":ai-application").projectDir = file("platform/ai/application") +project(":ai-adapter").projectDir = file("platform/ai/adapter") +project(":template-domain").projectDir = file("platform/template/domain") +project(":template-application").projectDir = file("platform/template/application") +project(":template-adapter").projectDir = file("platform/template/adapter") project(":platform-api").projectDir = file("platform/services/api")