Merge pull request #2 from won-ktds/feature/generate-poster

Feature/generate poster
This commit is contained in:
SeongRak Oh 2025-06-13 16:59:37 +09:00 committed by GitHub
commit 3aaa27e0fc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
207 changed files with 8523 additions and 3583 deletions

109
.idea/workspace.xml generated Normal file
View File

@ -0,0 +1,109 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="7d9c48b3-e5c8-4a1c-af9a-469e24fa5715" name="변경" comment="">
<change beforePath="$PROJECT_DIR$/.idea/.gitignore" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/gradle.xml" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/misc.xml" beforeDir="false" />
<change beforePath="$PROJECT_DIR$/.idea/vcs.xml" beforeDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="ExternalProjectsData">
<projectState path="$PROJECT_DIR$">
<ProjectState />
</projectState>
</component>
<component name="ExternalProjectsManager">
<system id="GRADLE">
<state>
<task path="$PROJECT_DIR$">
<activation />
</task>
<projects_view />
</state>
</system>
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="ProjectColorInfo">{
&quot;customColor&quot;: &quot;&quot;,
&quot;associatedIndex&quot;: 4
}</component>
<component name="ProjectId" id="2yLeuaqHXgKgtNCa4XzAZzifagS" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent"><![CDATA[{
"keyToString": {
"Gradle.member.executor": "Run",
"Gradle.소스 다운로드.executor": "Run",
"ModuleVcsDetector.initialDetectionPerformed": "true",
"RunOnceActivity.ShowReadmeOnStart": "true",
"RunOnceActivity.git.unshallow": "true",
"git-widget-placeholder": "main",
"last_opened_file_path": "C:/home/workspace/smarketing/smarketing-backend",
"project.structure.last.edited": "SDK",
"project.structure.proportion": "0.15",
"project.structure.side.proportion": "0.2",
"settings.editor.selected.configurable": "reference.settingsdialog.project.gradle"
}
}]]></component>
<component name="RunDashboard">
<option name="configurationTypes">
<set>
<option value="GradleRunConfiguration" />
</set>
</option>
</component>
<component name="RunManager">
<configuration name="member" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
<option name="env">
<map>
<entry key="POSTGRES_HOST" value="psql-digitalgarage-02.postgres.database.azure.com" />
<entry key="POSTGRES_PASSWORD" value="DG_Won!" />
<entry key="POSTGRES_USER" value="pgadmin" />
</map>
</option>
<option name="executionName" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="externalSystemIdString" value="GRADLE" />
<option name="passParentEnvs" value="false" />
<option name="scriptParameters" value="" />
<option name="taskDescriptions">
<list />
</option>
<option name="taskNames">
<list>
<option value=":member:bootRun" />
</list>
</option>
<option name="vmOptions" />
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<DebugAllEnabled>false</DebugAllEnabled>
<RunAsTest>false</RunAsTest>
<method v="2" />
</configuration>
</component>
<component name="TaskManager">
<task active="true" id="Default" summary="디폴트 작업">
<changelist id="7d9c48b3-e5c8-4a1c-af9a-469e24fa5715" name="변경" comment="" />
<created>1749618504890</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1749618504890</updated>
</task>
<servers />
</component>
</project>

View File

@ -1,8 +0,0 @@
tasks.getByName('bootJar') {
enabled = false
}
tasks.getByName('jar') {
enabled = true
archiveClassifier = ''
}

View File

@ -1,10 +0,0 @@
dependencies {
implementation project(':common')
// HTTP Client for external API
implementation 'org.springframework.boot:spring-boot-starter-webflux'
}
bootJar {
archiveFileName = "ai-recommend-service.jar"
}

View File

@ -1,20 +0,0 @@
package com.won.smarketing.recommend;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
/**
* AI 추천 서비스 메인 애플리케이션 클래스
* Clean Architecture 패턴을 적용한 AI 마케팅 추천 서비스
*/
@SpringBootApplication(scanBasePackages = {"com.won.smarketing.recommend", "com.won.smarketing.common"})
@EntityScan(basePackages = {"com.won.smarketing.recommend.infrastructure.entity"})
@EnableJpaRepositories(basePackages = {"com.won.smarketing.recommend.infrastructure.repository"})
public class AIRecommendServiceApplication {
public static void main(String[] args) {
SpringApplication.run(AIRecommendServiceApplication.class, args);
}
}

View File

@ -1,83 +0,0 @@
package com.won.smarketing.recommend.application.service;
import com.won.smarketing.common.exception.BusinessException;
import com.won.smarketing.common.exception.ErrorCode;
import com.won.smarketing.recommend.application.usecase.MarketingTipUseCase;
import com.won.smarketing.recommend.domain.model.MarketingTip;
import com.won.smarketing.recommend.domain.model.StoreData;
import com.won.smarketing.recommend.domain.model.TipId;
import com.won.smarketing.recommend.domain.model.WeatherData;
import com.won.smarketing.recommend.domain.repository.MarketingTipRepository;
import com.won.smarketing.recommend.domain.service.AiTipGenerator;
import com.won.smarketing.recommend.domain.service.StoreDataProvider;
import com.won.smarketing.recommend.domain.service.WeatherDataProvider;
import com.won.smarketing.recommend.presentation.dto.MarketingTipRequest;
import com.won.smarketing.recommend.presentation.dto.MarketingTipResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
/**
* 마케팅 서비스 구현체
* AI 기반 마케팅 생성 저장 기능 구현
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MarketingTipService implements MarketingTipUseCase {
private final MarketingTipRepository marketingTipRepository;
private final StoreDataProvider storeDataProvider;
private final WeatherDataProvider weatherDataProvider;
private final AiTipGenerator aiTipGenerator;
/**
* AI 마케팅 생성
*
* @param request 마케팅 생성 요청
* @return 생성된 마케팅 응답
*/
@Override
@Transactional
public MarketingTipResponse generateMarketingTips(MarketingTipRequest request) {
try {
// 매장 정보 조회
StoreData storeData = storeDataProvider.getStoreData(request.getStoreId());
log.debug("매장 정보 조회 완료: {}", storeData.getStoreName());
// 날씨 정보 조회
WeatherData weatherData = weatherDataProvider.getCurrentWeather(storeData.getLocation());
log.debug("날씨 정보 조회 완료: {} 도", weatherData.getTemperature());
// AI를 사용하여 마케팅 생성
String tipContent = aiTipGenerator.generateTip(storeData, weatherData);
log.debug("AI 마케팅 팁 생성 완료");
// 마케팅 도메인 객체 생성
MarketingTip marketingTip = MarketingTip.builder()
.storeId(request.getStoreId())
.tipContent(tipContent)
.weatherData(weatherData)
.storeData(storeData)
.createdAt(LocalDateTime.now())
.build();
// 마케팅 저장
MarketingTip savedTip = marketingTipRepository.save(marketingTip);
return MarketingTipResponse.builder()
.tipId(savedTip.getId().getValue())
.tipContent(savedTip.getTipContent())
.createdAt(savedTip.getCreatedAt())
.build();
} catch (Exception e) {
log.error("마케팅 팁 생성 중 오류 발생", e);
throw new BusinessException(ErrorCode.RECOMMENDATION_FAILED);
}
}
}

View File

@ -1,19 +0,0 @@
package com.won.smarketing.recommend.application.usecase;
import com.won.smarketing.recommend.presentation.dto.MarketingTipRequest;
import com.won.smarketing.recommend.presentation.dto.MarketingTipResponse;
/**
* 마케팅 관련 Use Case 인터페이스
* AI 기반 마케팅 생성 기능 정의
*/
public interface MarketingTipUseCase {
/**
* AI 마케팅 생성
*
* @param request 마케팅 생성 요청
* @return 생성된 마케팅 응답
*/
MarketingTipResponse generateMarketingTips(MarketingTipRequest request);
}

View File

@ -1,66 +0,0 @@
package com.won.smarketing.recommend.domain.model;
import lombok.*;
/**
* 매장 데이터 객체
* 마케팅 생성에 사용되는 매장 정보
*/
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
@EqualsAndHashCode
public class StoreData {
/**
* 매장명
*/
private String storeName;
/**
* 업종
*/
private String businessType;
/**
* 매장 위치 (주소)
*/
private String location;
/**
* 매장 데이터 유효성 검증
*
* @return 유효성 여부
*/
public boolean isValid() {
return storeName != null && !storeName.trim().isEmpty() &&
businessType != null && !businessType.trim().isEmpty() &&
location != null && !location.trim().isEmpty();
}
/**
* 업종 카테고리 분류
*
* @return 업종 카테고리
*/
public String getBusinessCategory() {
if (businessType == null) {
return "기타";
}
String lowerCaseType = businessType.toLowerCase();
if (lowerCaseType.contains("카페") || lowerCaseType.contains("커피")) {
return "카페";
} else if (lowerCaseType.contains("식당") || lowerCaseType.contains("레스토랑")) {
return "음식점";
} else if (lowerCaseType.contains("베이커리") || lowerCaseType.contains("")) {
return "베이커리";
} else if (lowerCaseType.contains("치킨") || lowerCaseType.contains("피자")) {
return "패스트푸드";
} else {
return "기타";
}
}
}

View File

@ -1,29 +0,0 @@
package com.won.smarketing.recommend.domain.model;
import lombok.*;
/**
* 마케팅 식별자 객체
* 마케팅 팁의 고유 식별자를 나타내는 도메인 객체
*/
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@EqualsAndHashCode
public class TipId {
private Long value;
/**
* TipId 생성 팩토리 메서드
*
* @param value 식별자
* @return TipId 인스턴스
*/
public static TipId of(Long value) {
if (value == null || value <= 0) {
throw new IllegalArgumentException("TipId는 양수여야 합니다.");
}
return new TipId(value);
}
}

View File

@ -1,66 +0,0 @@
package com.won.smarketing.recommend.domain.model;
import lombok.*;
/**
* 날씨 데이터 객체
* 마케팅 생성에 사용되는 날씨 정보
*/
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
@EqualsAndHashCode
public class WeatherData {
/**
* 온도 (섭씨)
*/
private Double temperature;
/**
* 날씨 상태 (맑음, 흐림, , )
*/
private String condition;
/**
* 습도 (%)
*/
private Double humidity;
/**
* 날씨 데이터 유효성 검증
*
* @return 유효성 여부
*/
public boolean isValid() {
return temperature != null &&
condition != null && !condition.trim().isEmpty() &&
humidity != null && humidity >= 0 && humidity <= 100;
}
/**
* 온도 기반 날씨 상태 설명
*
* @return 날씨 상태 설명
*/
public String getTemperatureDescription() {
if (temperature == null) {
return "알 수 없음";
}
if (temperature >= 30) {
return "매우 더움";
} else if (temperature >= 25) {
return "더움";
} else if (temperature >= 20) {
return "따뜻함";
} else if (temperature >= 10) {
return "선선함";
} else if (temperature >= 0) {
return "춥다";
} else {
return "매우 춥다";
}
}
}

View File

@ -1,56 +0,0 @@
package com.won.smarketing.recommend.domain.repository;
import com.won.smarketing.recommend.domain.model.MarketingTip;
import com.won.smarketing.recommend.domain.model.TipId;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
/**
* 마케팅 저장소 인터페이스
* 마케팅 도메인의 데이터 접근 추상화
*/
public interface MarketingTipRepository {
/**
* 마케팅 저장
*
* @param marketingTip 저장할 마케팅
* @return 저장된 마케팅
*/
MarketingTip save(MarketingTip marketingTip);
/**
* 마케팅 ID로 조회
*
* @param id 마케팅 ID
* @return 마케팅 (Optional)
*/
Optional<MarketingTip> findById(TipId id);
/**
* 매장별 마케팅 목록 조회
*
* @param storeId 매장 ID
* @return 마케팅 목록
*/
List<MarketingTip> findByStoreId(Long storeId);
/**
* 특정 기간 생성된 마케팅 조회
*
* @param storeId 매장 ID
* @param startDate 시작 시각
* @param endDate 종료 시각
* @return 마케팅 목록
*/
List<MarketingTip> findByStoreIdAndCreatedAtBetween(Long storeId, LocalDateTime startDate, LocalDateTime endDate);
/**
* 마케팅 삭제
*
* @param id 삭제할 마케팅 ID
*/
void deleteById(TipId id);
}

View File

@ -1,20 +0,0 @@
package com.won.smarketing.recommend.domain.service;
import com.won.smarketing.recommend.domain.model.StoreData;
import com.won.smarketing.recommend.domain.model.WeatherData;
/**
* AI 생성 도메인 서비스 인터페이스
* AI를 활용한 마케팅 생성 기능 정의
*/
public interface AiTipGenerator {
/**
* 매장 정보와 날씨 정보를 바탕으로 마케팅 생성
*
* @param storeData 매장 데이터
* @param weatherData 날씨 데이터
* @return AI가 생성한 마케팅
*/
String generateTip(StoreData storeData, WeatherData weatherData);
}

View File

@ -1,18 +0,0 @@
package com.won.smarketing.recommend.domain.service;
import com.won.smarketing.recommend.domain.model.WeatherData;
/**
* 날씨 데이터 제공 도메인 서비스 인터페이스
* 외부 날씨 API로부터 날씨 정보 조회 기능 정의
*/
public interface WeatherDataProvider {
/**
* 특정 위치의 현재 날씨 정보 조회
*
* @param location 위치 (주소)
* @return 날씨 데이터
*/
WeatherData getCurrentWeather(String location);
}

View File

@ -1,248 +0,0 @@
package com.won.smarketing.recommend.infrastructure.external;
import com.won.smarketing.common.exception.BusinessException;
import com.won.smarketing.common.exception.ErrorCode;
import com.won.smarketing.recommend.domain.model.StoreData;
import com.won.smarketing.recommend.domain.model.WeatherData;
import com.won.smarketing.recommend.domain.service.AiTipGenerator;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import reactor.core.publisher.Mono;
import java.time.Duration;
import java.util.Map;
/**
* Claude AI 생성기 구현체
* Claude AI API를 통해 마케팅 생성
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ClaudeAiTipGenerator implements AiTipGenerator {
private final WebClient webClient;
@Value("${external.claude-ai.api-key}")
private String claudeApiKey;
@Value("${external.claude-ai.base-url}")
private String claudeApiBaseUrl;
@Value("${external.claude-ai.model}")
private String claudeModel;
@Value("${external.claude-ai.max-tokens}")
private Integer maxTokens;
/**
* 매장 정보와 날씨 정보를 바탕으로 마케팅 생성
*
* @param storeData 매장 데이터
* @param weatherData 날씨 데이터
* @return AI가 생성한 마케팅
*/
@Override
public String generateTip(StoreData storeData, WeatherData weatherData) {
try {
log.debug("AI 마케팅 팁 생성 시작: store={}, weather={}도",
storeData.getStoreName(), weatherData.getTemperature());
String prompt = buildPrompt(storeData, weatherData);
Map<String, Object> requestBody = Map.of(
"model", claudeModel,
"max_tokens", maxTokens,
"messages", new Object[]{
Map.of(
"role", "user",
"content", prompt
)
}
);
ClaudeApiResponse response = webClient
.post()
.uri(claudeApiBaseUrl + "/v1/messages")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + claudeApiKey)
.header("anthropic-version", "2023-06-01")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(requestBody)
.retrieve()
.bodyToMono(ClaudeApiResponse.class)
.timeout(Duration.ofSeconds(30))
.block();
if (response == null || response.getContent() == null || response.getContent().length == 0) {
throw new BusinessException(ErrorCode.AI_SERVICE_UNAVAILABLE);
}
String generatedTip = response.getContent()[0].getText();
// 100자 제한 적용
if (generatedTip.length() > 100) {
generatedTip = generatedTip.substring(0, 97) + "...";
}
log.debug("AI 마케팅 팁 생성 완료: length={}", generatedTip.length());
return generatedTip;
} catch (WebClientResponseException e) {
log.error("Claude AI API 호출 실패: status={}", e.getStatusCode(), e);
return generateFallbackTip(storeData, weatherData);
} catch (Exception e) {
log.error("AI 마케팅 팁 생성 중 오류 발생", e);
return generateFallbackTip(storeData, weatherData);
}
}
/**
* AI 프롬프트 구성
*
* @param storeData 매장 데이터
* @param weatherData 날씨 데이터
* @return 프롬프트 문자열
*/
private String buildPrompt(StoreData storeData, WeatherData weatherData) {
return String.format(
"다음 매장을 위한 오늘의 마케팅 팁을 100자 이내로 작성해주세요.\n\n" +
"매장 정보:\n" +
"- 매장명: %s\n" +
"- 업종: %s\n" +
"- 위치: %s\n\n" +
"오늘 날씨:\n" +
"- 온도: %.1f도\n" +
"- 날씨: %s\n" +
"- 습도: %.1f%%\n\n" +
"날씨와 매장 특성을 고려한 실용적이고 구체적인 마케팅 팁을 제안해주세요. " +
"반드시 100자 이내로 작성하고, 친근하고 실행 가능한 조언을 해주세요.",
storeData.getStoreName(),
storeData.getBusinessType(),
storeData.getLocation(),
weatherData.getTemperature(),
weatherData.getCondition(),
weatherData.getHumidity()
);
}
/**
* AI API 실패 대체 생성
*
* @param storeData 매장 데이터
* @param weatherData 날씨 데이터
* @return 대체 마케팅
*/
private String generateFallbackTip(StoreData storeData, WeatherData weatherData) {
StringBuilder tip = new StringBuilder();
// 날씨 기반 기본
if (weatherData.getTemperature() >= 25) {
tip.append("더운 날씨에는 시원한 음료나 디저트를 홍보해보세요! ");
} else if (weatherData.getTemperature() <= 10) {
tip.append("추운 날씨에는 따뜻한 메뉴를 강조해보세요! ");
} else {
tip.append("좋은 날씨를 활용한 야외석 이용을 추천해보세요! ");
}
// 업종별 기본
String businessCategory = storeData.getBusinessCategory();
switch (businessCategory) {
case "카페":
tip.append("인스타그램용 예쁜 음료 사진을 올려보세요.");
break;
case "음식점":
tip.append("시그니처 메뉴의 맛있는 사진을 SNS에 공유해보세요.");
break;
default:
tip.append("오늘의 특별 메뉴를 SNS에 홍보해보세요.");
break;
}
String fallbackTip = tip.toString();
return fallbackTip.length() > 100 ? fallbackTip.substring(0, 97) + "..." : fallbackTip;
}
/**
* Claude API 응답 DTO
*/
private static class ClaudeApiResponse {
private Content[] content;
public Content[] getContent() { return content; }
public void setContent(Content[] content) { this.content = content; }
static class Content {
private String text;
private String type;
public String getText() { return text; }
public void setText(String text) { this.text = text; }
public String getType() { return type; }
public void setType(String type) { this.type = type; }
}
}
}/model/MarketingTip.java
package com.won.smarketing.recommend.domain.model;
import lombok.*;
import java.time.LocalDateTime;
/**
* 마케팅 도메인 모델
* AI가 생성한 마케팅 팁과 관련 정보를 관리
*/
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class MarketingTip {
/**
* 마케팅 고유 식별자
*/
private TipId id;
/**
* 매장 ID
*/
private Long storeId;
/**
* AI가 생성한 마케팅 내용
*/
private String tipContent;
/**
* 생성 참고한 날씨 데이터
*/
private WeatherData weatherData;
/**
* 생성 참고한 매장 데이터
*/
private StoreData storeData;
/**
* 생성 시각
*/
private LocalDateTime createdAt;
/**
* 내용 업데이트
*
* @param newContent 새로운 내용
*/
public void updateContent(String newContent) {
if (newContent == null || newContent.trim().isEmpty()) {
throw new IllegalArgumentException("팁 내용은 비어있을 수 없습니다.");
}
this.tipContent = newContent.trim();
}
}

View File

@ -1,110 +0,0 @@
package com.won.smarketing.recommend.infrastructure.external;
import com.won.smarketing.common.exception.BusinessException;
import com.won.smarketing.common.exception.ErrorCode;
import com.won.smarketing.recommend.domain.model.StoreData;
import com.won.smarketing.recommend.domain.service.StoreDataProvider;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import reactor.core.publisher.Mono;
import java.time.Duration;
/**
* 매장 API 데이터 제공자 구현체
* 외부 매장 서비스 API를 통해 매장 정보 조회
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class StoreApiDataProvider implements StoreDataProvider {
private final WebClient webClient;
@Value("${external.store-service.base-url}")
private String storeServiceBaseUrl;
/**
* 매장 ID로 매장 데이터 조회
*
* @param storeId 매장 ID
* @return 매장 데이터
*/
@Override
public StoreData getStoreData(Long storeId) {
try {
log.debug("매장 정보 조회 시작: storeId={}", storeId);
StoreApiResponse response = webClient
.get()
.uri(storeServiceBaseUrl + "/api/store?storeId=" + storeId)
.retrieve()
.bodyToMono(StoreApiResponse.class)
.timeout(Duration.ofSeconds(10))
.block();
if (response == null || response.getData() == null) {
throw new BusinessException(ErrorCode.STORE_NOT_FOUND);
}
StoreApiData storeApiData = response.getData();
StoreData storeData = StoreData.builder()
.storeName(storeApiData.getStoreName())
.businessType(storeApiData.getBusinessType())
.location(storeApiData.getAddress())
.build();
log.debug("매장 정보 조회 완료: {}", storeData.getStoreName());
return storeData;
} catch (WebClientResponseException e) {
log.error("매장 서비스 API 호출 실패: storeId={}, status={}", storeId, e.getStatusCode(), e);
throw new BusinessException(ErrorCode.EXTERNAL_API_ERROR);
} catch (Exception e) {
log.error("매장 정보 조회 중 오류 발생: storeId={}", storeId, e);
throw new BusinessException(ErrorCode.EXTERNAL_API_ERROR);
}
}
/**
* 매장 API 응답 DTO
*/
private static class StoreApiResponse {
private int status;
private String message;
private StoreApiData data;
// Getters and Setters
public int getStatus() { return status; }
public void setStatus(int status) { this.status = status; }
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
public StoreApiData getData() { return data; }
public void setData(StoreApiData data) { this.data = data; }
}
/**
* 매장 API 데이터 DTO
*/
private static class StoreApiData {
private Long storeId;
private String storeName;
private String businessType;
private String address;
// Getters and Setters
public Long getStoreId() { return storeId; }
public void setStoreId(Long storeId) { this.storeId = storeId; }
public String getStoreName() { return storeName; }
public void setStoreName(String storeName) { this.storeName = storeName; }
public String getBusinessType() { return businessType; }
public void setBusinessType(String businessType) { this.businessType = businessType; }
public String getAddress() { return address; }
public void setAddress(String address) { this.address = address; }
}
}

View File

@ -1,155 +0,0 @@
package com.won.smarketing.recommend.infrastructure.external;
import com.won.smarketing.common.exception.BusinessException;
import com.won.smarketing.common.exception.ErrorCode;
import com.won.smarketing.recommend.domain.model.WeatherData;
import com.won.smarketing.recommend.domain.service.WeatherDataProvider;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import reactor.core.publisher.Mono;
import java.time.Duration;
/**
* 날씨 API 데이터 제공자 구현체
* 외부 날씨 API를 통해 날씨 정보 조회
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class WeatherApiDataProvider implements WeatherDataProvider {
private final WebClient webClient;
@Value("${external.weather-api.api-key}")
private String weatherApiKey;
@Value("${external.weather-api.base-url}")
private String weatherApiBaseUrl;
/**
* 특정 위치의 현재 날씨 정보 조회
*
* @param location 위치 (주소)
* @return 날씨 데이터
*/
@Override
public WeatherData getCurrentWeather(String location) {
try {
log.debug("날씨 정보 조회 시작: location={}", location);
// 한국 주요 도시로 단순화
String city = extractCity(location);
WeatherApiResponse response = webClient
.get()
.uri(uriBuilder -> uriBuilder
.scheme("https")
.host("api.openweathermap.org")
.path("/data/2.5/weather")
.queryParam("q", city + ",KR")
.queryParam("appid", weatherApiKey)
.queryParam("units", "metric")
.queryParam("lang", "kr")
.build())
.retrieve()
.bodyToMono(WeatherApiResponse.class)
.timeout(Duration.ofSeconds(10))
.onErrorReturn(createDefaultWeatherData()) // 오류 기본값 반환
.block();
if (response == null) {
return createDefaultWeatherData();
}
WeatherData weatherData = WeatherData.builder()
.temperature(response.getMain().getTemp())
.condition(response.getWeather()[0].getDescription())
.humidity(response.getMain().getHumidity())
.build();
log.debug("날씨 정보 조회 완료: {}도, {}", weatherData.getTemperature(), weatherData.getCondition());
return weatherData;
} catch (Exception e) {
log.warn("날씨 정보 조회 실패, 기본값 사용: location={}", location, e);
return createDefaultWeatherData();
}
}
/**
* 주소에서 도시명 추출
*
* @param location 전체 주소
* @return 도시명
*/
private String extractCity(String location) {
if (location == null || location.trim().isEmpty()) {
return "Seoul";
}
// 서울, 부산, 대구, 인천, 광주, 대전, 울산 주요 도시 추출
String[] cities = {"서울", "부산", "대구", "인천", "광주", "대전", "울산", "세종", "수원", "창원"};
for (String city : cities) {
if (location.contains(city)) {
return city;
}
}
return "Seoul"; // 기본값
}
/**
* 기본 날씨 데이터 생성 (API 호출 실패 사용)
*
* @return 기본 날씨 데이터
*/
private WeatherApiResponse createDefaultWeatherData() {
WeatherApiResponse response = new WeatherApiResponse();
response.setMain(new WeatherApiResponse.Main());
response.getMain().setTemp(20.0); // 기본 온도 20도
response.getMain().setHumidity(60.0); // 기본 습도 60%
WeatherApiResponse.Weather[] weather = new WeatherApiResponse.Weather[1];
weather[0] = new WeatherApiResponse.Weather();
weather[0].setDescription("맑음");
response.setWeather(weather);
return response;
}
/**
* 날씨 API 응답 DTO
*/
private static class WeatherApiResponse {
private Main main;
private Weather[] weather;
public Main getMain() { return main; }
public void setMain(Main main) { this.main = main; }
public Weather[] getWeather() { return weather; }
public void setWeather(Weather[] weather) { this.weather = weather; }
static class Main {
private Double temp;
private Double humidity;
public Double getTemp() { return temp; }
public void setTemp(Double temp) { this.temp = temp; }
public Double getHumidity() { return humidity; }
public void setHumidity(Double humidity) { this.humidity = humidity; }
}
static class Weather {
private String description;
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
}
}
}

View File

@ -1,39 +0,0 @@
package com.won.smarketing.recommend.presentation.controller;
import com.won.smarketing.common.dto.ApiResponse;
import com.won.smarketing.recommend.application.usecase.MarketingTipUseCase;
import com.won.smarketing.recommend.presentation.dto.MarketingTipRequest;
import com.won.smarketing.recommend.presentation.dto.MarketingTipResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
/**
* AI 마케팅 추천을 위한 REST API 컨트롤러
* AI 기반 마케팅 생성 기능 제공
*/
@Tag(name = "AI 마케팅 추천", description = "AI 기반 맞춤형 마케팅 추천 API")
@RestController
@RequestMapping("/api/recommendation")
@RequiredArgsConstructor
public class RecommendationController {
private final MarketingTipUseCase marketingTipUseCase;
/**
* AI 마케팅 생성
*
* @param request 마케팅 생성 요청
* @return 생성된 마케팅
*/
@Operation(summary = "AI 마케팅 팁 생성", description = "매장 특성과 환경 정보를 바탕으로 AI 마케팅 팁을 생성합니다.")
@PostMapping("/marketing-tips")
public ResponseEntity<ApiResponse<MarketingTipResponse>> generateMarketingTips(@Valid @RequestBody MarketingTipRequest request) {
MarketingTipResponse response = marketingTipUseCase.generateMarketingTips(request);
return ResponseEntity.ok(ApiResponse.success(response, "AI 마케팅 팁이 성공적으로 생성되었습니다."));
}
}

View File

@ -1,34 +0,0 @@
package com.won.smarketing.recommend.presentation.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 상세 AI 마케팅 응답 DTO
* AI 마케팅 팁과 함께 생성 사용된 환경 데이터도 포함합니다.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "상세 AI 마케팅 팁 응답")
public class DetailedMarketingTipResponse {
@Schema(description = "팁 ID", example = "1")
private Long tipId;
@Schema(description = "AI 생성 마케팅 팁 내용 (100자 이내)")
private String tipContent;
@Schema(description = "팁 생성 시간", example = "2024-01-15T10:30:00")
private LocalDateTime createdAt;
@Schema(description = "팁 생성 시 참고된 날씨 정보")
private WeatherInfoDto weatherInfo;
@Schema(description = "팁 생성 시 참고된 매장 정보")
private StoreInfoDto storeInfo;
}

View File

@ -1,31 +0,0 @@
package com.won.smarketing.recommend.presentation.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 에러 응답 DTO
* AI 추천 서비스에서 발생하는 에러 정보를 전달합니다.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "에러 응답")
public class ErrorResponseDto {
@Schema(description = "에러 코드", example = "AI_SERVICE_ERROR")
private String errorCode;
@Schema(description = "에러 메시지", example = "AI 서비스 연결에 실패했습니다")
private String message;
@Schema(description = "에러 발생 시간", example = "2024-01-15T10:30:00")
private LocalDateTime timestamp;
@Schema(description = "요청 경로", example = "/api/recommendation/marketing-tips")
private String path;
}

View File

@ -1,29 +0,0 @@
package com.won.smarketing.recommend.presentation.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* AI 마케팅 생성을 위한 내부 요청 DTO
* 애플리케이션 계층에서 AI 서비스 호출 사용됩니다.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "AI 마케팅 팁 생성 내부 요청")
public class MarketingTipGenerationRequest {
@NotNull(message = "매장 정보는 필수입니다")
@Schema(description = "매장 정보", required = true)
private StoreInfoDto storeInfo;
@Schema(description = "현재 날씨 정보")
private WeatherInfoDto weatherInfo;
@Schema(description = "팁 생성 옵션", example = "일반")
private String tipType;
}

View File

@ -1,29 +0,0 @@
package com.won.smarketing.recommend.presentation.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* AI 마케팅 생성 응답 DTO
* AI가 생성한 개인화된 마케팅 정보를 전달합니다.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "AI 마케팅 팁 생성 응답")
public class MarketingTipResponse {
@Schema(description = "팁 ID", example = "1")
private Long tipId;
@Schema(description = "AI 생성 마케팅 팁 내용 (100자 이내)",
example = "오늘 같은 비 오는 날에는 따뜻한 음료와 함께 실내 분위기를 강조한 포스팅을 올려보세요. #비오는날카페 #따뜻한음료 해시태그로 감성을 어필해보세요!")
private String tipContent;
@Schema(description = "팁 생성 시간", example = "2024-01-15T10:30:00")
private LocalDateTime createdAt;
}

View File

@ -1,26 +0,0 @@
package com.won.smarketing.recommend.presentation.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 매장 정보 DTO
* AI 마케팅 생성 매장 특성을 반영하기 위한 정보입니다.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "매장 정보")
public class StoreInfoDto {
@Schema(description = "매장명", example = "카페 원더풀")
private String storeName;
@Schema(description = "업종", example = "카페")
private String businessType;
@Schema(description = "매장 위치", example = "서울시 강남구")
private String location;
}

View File

@ -1,26 +0,0 @@
package com.won.smarketing.recommend.presentation.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 날씨 정보 DTO
* AI 마케팅 생성 참고되는 환경 데이터입니다.
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "날씨 정보")
public class WeatherInfoDto {
@Schema(description = "기온 (섭씨)", example = "23.5")
private Double temperature;
@Schema(description = "날씨 상태", example = "맑음")
private String condition;
@Schema(description = "습도 (%)", example = "65.0")
private Double humidity;
}

View File

@ -1,41 +0,0 @@
package com.won.smarketing.common.config;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Swagger 설정 클래스
* API 문서화를 위한 OpenAPI 설정
*/
@Configuration
public class SwaggerConfig {
/**
* OpenAPI 설정
*
* @return OpenAPI 인스턴스
*/
@Bean
public OpenAPI openAPI() {
String securitySchemeName = "bearerAuth";
return new OpenAPI()
.info(new Info()
.title("AI 마케팅 서비스 API")
.description("소상공인을 위한 맞춤형 AI 마케팅 솔루션 API 문서")
.version("v1.0.0"))
.addSecurityItem(new SecurityRequirement().addList(securitySchemeName))
.components(new Components()
.addSecuritySchemes(securitySchemeName,
new SecurityScheme()
.name(securitySchemeName)
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")));
}
}

View File

@ -1,44 +0,0 @@
package com.won.smarketing.common.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 페이지네이션 응답 DTO
* 페이지 단위 조회 결과를 담는 공통 형식
*
* @param <T> 페이지 내용의 데이터 타입
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "페이지네이션 응답")
public class PageResponse<T> {
@Schema(description = "페이지 내용")
private List<T> content;
@Schema(description = "현재 페이지 번호", example = "0")
private int pageNumber;
@Schema(description = "페이지 크기", example = "20")
private int pageSize;
@Schema(description = "전체 요소 수", example = "100")
private long totalElements;
@Schema(description = "전체 페이지 수", example = "5")
private int totalPages;
@Schema(description = "첫 번째 페이지 여부", example = "true")
private boolean first;
@Schema(description = "마지막 페이지 여부", example = "false")
private boolean last;
}

View File

@ -1,151 +0,0 @@
package com.won.smarketing.common.exception;
import com.won.smarketing.common.dto.ApiResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.BindException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import java.nio.file.AccessDeniedException;
/**
* 전역 예외 처리 핸들러
* 애플리케이션에서 발생하는 모든 예외를 처리하여 일관된 응답 형식 제공
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 비즈니스 예외 처리
*
* @param ex 비즈니스 예외
* @return 오류 응답
*/
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiResponse<Void>> handleBusinessException(BusinessException ex) {
log.warn("Business exception occurred: {}", ex.getMessage());
ErrorCode errorCode = ex.getErrorCode();
return ResponseEntity
.status(errorCode.getHttpStatus())
.body(ApiResponse.error(errorCode.getHttpStatus().value(), ex.getMessage()));
}
/**
* 유효성 검증 예외 처리 (@Valid 애노테이션)
*
* @param ex 유효성 검증 예외
* @return 오류 응답
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleValidationException(MethodArgumentNotValidException ex) {
log.warn("Validation exception occurred: {}", ex.getMessage());
String errorMessage = ex.getBindingResult()
.getFieldErrors()
.stream()
.findFirst()
.map(error -> error.getDefaultMessage())
.orElse("유효성 검증에 실패했습니다.");
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), errorMessage));
}
/**
* 바인딩 예외 처리
*
* @param ex 바인딩 예외
* @return 오류 응답
*/
@ExceptionHandler(BindException.class)
public ResponseEntity<ApiResponse<Void>> handleBindException(BindException ex) {
log.warn("Bind exception occurred: {}", ex.getMessage());
String errorMessage = ex.getBindingResult()
.getFieldErrors()
.stream()
.findFirst()
.map(error -> error.getDefaultMessage())
.orElse("요청 데이터 바인딩에 실패했습니다.");
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), errorMessage));
}
/**
* 타입 불일치 예외 처리
*
* @param ex 타입 불일치 예외
* @return 오류 응답
*/
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<ApiResponse<Void>> handleTypeMismatchException(MethodArgumentTypeMismatchException ex) {
log.warn("Type mismatch exception occurred: {}", ex.getMessage());
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), "잘못된 타입의 값입니다."));
}
/**
* 필수 파라미터 누락 예외 처리
*
* @param ex 필수 파라미터 누락 예외
* @return 오류 응답
*/
@ExceptionHandler(MissingServletRequestParameterException.class)
public ResponseEntity<ApiResponse<Void>> handleMissingParameterException(MissingServletRequestParameterException ex) {
log.warn("Missing parameter exception occurred: {}", ex.getMessage());
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(ApiResponse.error(HttpStatus.BAD_REQUEST.value(), "필수 요청 파라미터가 누락되었습니다: " + ex.getParameterName()));
}
/**
* HTTP 메서드 불일치 예외 처리
*
* @param ex HTTP 메서드 불일치 예외
* @return 오류 응답
*/
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
public ResponseEntity<ApiResponse<Void>> handleMethodNotSupportedException(HttpRequestMethodNotSupportedException ex) {
log.warn("Method not supported exception occurred: {}", ex.getMessage());
return ResponseEntity
.status(HttpStatus.METHOD_NOT_ALLOWED)
.body(ApiResponse.error(HttpStatus.METHOD_NOT_ALLOWED.value(), "허용되지 않은 HTTP 메서드입니다."));
}
/**
* 접근 거부 예외 처리
*
* @param ex 접근 거부 예외
* @return 오류 응답
*/
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ApiResponse<Void>> handleAccessDeniedException(AccessDeniedException ex) {
log.warn("Access denied exception occurred: {}", ex.getMessage());
return ResponseEntity
.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error(HttpStatus.FORBIDDEN.value(), "접근이 거부되었습니다."));
}
/**
* 기타 모든 예외 처리
*
* @param ex 예외
* @return 오류 응답
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleGenericException(Exception ex) {
log.error("Unexpected exception occurred", ex);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "서버 내부 오류가 발생했습니다."));
}
}

View File

@ -1,150 +0,0 @@
package com.won.smarketing.common.security;
import com.won.smarketing.common.exception.BusinessException;
import com.won.smarketing.common.exception.ErrorCode;
import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.util.Date;
/**
* JWT 토큰 생성 검증 유틸리티
* Access Token과 Refresh Token 관리
*/
@Slf4j
@Component
public class JwtTokenProvider {
private final SecretKey key;
private final long accessTokenValidityTime;
private final long refreshTokenValidityTime;
/**
* JWT 토큰 프로바이더 생성자
*
* @param secretKey JWT 서명에 사용할 비밀키
* @param accessTokenValidityTime Access Token 유효 시간 (밀리초)
* @param refreshTokenValidityTime Refresh Token 유효 시간 (밀리초)
*/
public JwtTokenProvider(
@Value("${jwt.secret-key}") String secretKey,
@Value("${jwt.access-token-validity}") long accessTokenValidityTime,
@Value("${jwt.refresh-token-validity}") long refreshTokenValidityTime) {
byte[] keyBytes = Decoders.BASE64.decode(secretKey);
this.key = Keys.hmacShaKeyFor(keyBytes);
this.accessTokenValidityTime = accessTokenValidityTime;
this.refreshTokenValidityTime = refreshTokenValidityTime;
}
/**
* Access Token 생성
*
* @param userId 사용자 ID
* @return Access Token
*/
public String generateAccessToken(String userId) {
long now = System.currentTimeMillis();
Date validity = new Date(now + accessTokenValidityTime);
return Jwts.builder()
.setSubject(userId)
.setIssuedAt(new Date(now))
.setExpiration(validity)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
/**
* Refresh Token 생성
*
* @param userId 사용자 ID
* @return Refresh Token
*/
public String generateRefreshToken(String userId) {
long now = System.currentTimeMillis();
Date validity = new Date(now + refreshTokenValidityTime);
return Jwts.builder()
.setSubject(userId)
.setIssuedAt(new Date(now))
.setExpiration(validity)
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
/**
* 토큰에서 사용자 ID 추출
*
* @param token JWT 토큰
* @return 사용자 ID
*/
public String getUserIdFromToken(String token) {
Claims claims = parseClaims(token);
return claims.getSubject();
}
/**
* 토큰 유효성 검증
*
* @param token JWT 토큰
* @return 유효성 여부
*/
public boolean validateToken(String token) {
try {
parseClaims(token);
return true;
} catch (ExpiredJwtException e) {
log.warn("Expired JWT token: {}", e.getMessage());
throw new BusinessException(ErrorCode.TOKEN_EXPIRED);
} catch (UnsupportedJwtException e) {
log.warn("Unsupported JWT token: {}", e.getMessage());
throw new BusinessException(ErrorCode.INVALID_TOKEN);
} catch (MalformedJwtException e) {
log.warn("Malformed JWT token: {}", e.getMessage());
throw new BusinessException(ErrorCode.INVALID_TOKEN);
} catch (SecurityException e) {
log.warn("Invalid JWT signature: {}", e.getMessage());
throw new BusinessException(ErrorCode.INVALID_TOKEN);
} catch (IllegalArgumentException e) {
log.warn("JWT token compact of handler are invalid: {}", e.getMessage());
throw new BusinessException(ErrorCode.INVALID_TOKEN);
}
}
/**
* 토큰에서 Claims 추출
*
* @param token JWT 토큰
* @return Claims
*/
private Claims parseClaims(String token) {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
}
/**
* Access Token 유효 시간 반환
*
* @return Access Token 유효 시간 (밀리초)
*/
public long getAccessTokenValidityTime() {
return accessTokenValidityTime;
}
/**
* Refresh Token 유효 시간 반환
*
* @return Refresh Token 유효 시간 (밀리초)
*/
public long getRefreshTokenValidityTime() {
return refreshTokenValidityTime;
}
}

View File

@ -1,10 +0,0 @@
dependencies {
implementation project(':common')
// HTTP Client for external AI API
implementation 'org.springframework.boot:spring-boot-starter-webflux'
}
bootJar {
archiveFileName = "marketing-content-service.jar"
}

View File

@ -1,114 +0,0 @@
package com.won.smarketing.content.domain.model;
import lombok.*;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/**
* 마케팅 콘텐츠 도메인 모델
* 콘텐츠의 핵심 비즈니스 로직과 상태를 관리
*/
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class Content {
/**
* 콘텐츠 고유 식별자
*/
private ContentId id;
/**
* 콘텐츠 타입 (SNS 게시물, 포스터 )
*/
private ContentType contentType;
/**
* 플랫폼 (인스타그램, 네이버 블로그 )
*/
private Platform platform;
/**
* 콘텐츠 제목
*/
private String title;
/**
* 콘텐츠 내용
*/
private String content;
/**
* 해시태그 목록
*/
private List<String> hashtags;
/**
* 이미지 URL 목록
*/
private List<String> images;
/**
* 콘텐츠 상태
*/
private ContentStatus status;
/**
* 콘텐츠 생성 조건
*/
private CreationConditions creationConditions;
/**
* 매장 ID
*/
private Long storeId;
/**
* 생성 시각
*/
private LocalDateTime createdAt;
/**
* 수정 시각
*/
private LocalDateTime updatedAt;
/**
* 콘텐츠 제목 업데이트
*
* @param title 새로운 제목
*/
public void updateTitle(String title) {
this.title = title;
this.updatedAt = LocalDateTime.now();
}
/**
* 콘텐츠 기간 업데이트
*
* @param startDate 시작일
* @param endDate 종료일
*/
public void updatePeriod(LocalDate startDate, LocalDate endDate) {
if (this.creationConditions != null) {
this.creationConditions = this.creationConditions.toBuilder()
.startDate(startDate)
.endDate(endDate)
.build();
}
this.updatedAt = LocalDateTime.now();
}
/**
* 콘텐츠 상태 변경
*
* @param status 새로운 상태
*/
public void changeStatus(ContentStatus status) {
this.status = status;
this.updatedAt = LocalDateTime.now();
}
}

View File

@ -1,33 +0,0 @@
package com.won.smarketing.content.domain.model;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 콘텐츠 식별자 객체
* 콘텐츠의 고유 식별자를 나타내는 도메인 객체
*/
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@EqualsAndHashCode
public class ContentId {
private Long value;
/**
* ContentId 생성 팩토리 메서드
*
* @param value 식별자
* @return ContentId 인스턴스
*/
public static ContentId of(Long value) {
if (value == null || value <= 0) {
throw new IllegalArgumentException("ContentId는 양수여야 합니다.");
}
return new ContentId(value);
}
}

View File

@ -1,39 +0,0 @@
package com.won.smarketing.content.domain.model;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 콘텐츠 상태 열거형
* 콘텐츠의 생명주기 상태 정의
*/
@Getter
@RequiredArgsConstructor
public enum ContentStatus {
DRAFT("임시저장"),
PUBLISHED("발행됨"),
ARCHIVED("보관됨");
private final String displayName;
/**
* 문자열로부터 ContentStatus 변환
*
* @param status 상태 문자열
* @return ContentStatus
*/
public static ContentStatus fromString(String status) {
if (status == null) {
return DRAFT;
}
for (ContentStatus s : ContentStatus.values()) {
if (s.name().equalsIgnoreCase(status)) {
return s;
}
}
throw new IllegalArgumentException("알 수 없는 콘텐츠 상태: " + status);
}
}

View File

@ -1,38 +0,0 @@
package com.won.smarketing.content.domain.model;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 콘텐츠 타입 열거형
* 지원되는 마케팅 콘텐츠 유형 정의
*/
@Getter
@RequiredArgsConstructor
public enum ContentType {
SNS_POST("SNS 게시물"),
POSTER("홍보 포스터");
private final String displayName;
/**
* 문자열로부터 ContentType 변환
*
* @param type 타입 문자열
* @return ContentType
*/
public static ContentType fromString(String type) {
if (type == null) {
return null;
}
for (ContentType contentType : ContentType.values()) {
if (contentType.name().equalsIgnoreCase(type)) {
return contentType;
}
}
throw new IllegalArgumentException("알 수 없는 콘텐츠 타입: " + type);
}
}

View File

@ -1,56 +0,0 @@
package com.won.smarketing.content.domain.model;
import lombok.*;
import java.time.LocalDate;
/**
* 콘텐츠 생성 조건 도메인 모델
* AI 콘텐츠 생성 사용되는 조건 정보
*/
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder(toBuilder = true)
public class CreationConditions {
/**
* 홍보 대상 카테고리
*/
private String category;
/**
* 특별 요구사항
*/
private String requirement;
/**
* 톤앤매너
*/
private String toneAndManner;
/**
* 감정 강도
*/
private String emotionIntensity;
/**
* 이벤트명
*/
private String eventName;
/**
* 홍보 시작일
*/
private LocalDate startDate;
/**
* 홍보 종료일
*/
private LocalDate endDate;
/**
* 사진 스타일 (포스터용)
*/
private String photoStyle;
}

View File

@ -1,39 +0,0 @@
package com.won.smarketing.content.domain.model;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 플랫폼 열거형
* 콘텐츠가 게시될 플랫폼 정의
*/
@Getter
@RequiredArgsConstructor
public enum Platform {
INSTAGRAM("인스타그램"),
NAVER_BLOG("네이버 블로그"),
GENERAL("범용");
private final String displayName;
/**
* 문자열로부터 Platform 변환
*
* @param platform 플랫폼 문자열
* @return Platform
*/
public static Platform fromString(String platform) {
if (platform == null) {
return GENERAL;
}
for (Platform p : Platform.values()) {
if (p.name().equalsIgnoreCase(platform)) {
return p;
}
}
throw new IllegalArgumentException("알 수 없는 플랫폼: " + platform);
}
}

View File

@ -1,38 +0,0 @@
server:
port: ${SERVER_PORT:8083}
servlet:
context-path: /
spring:
application:
name: marketing-content-service
datasource:
url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:contentdb}
username: ${POSTGRES_USER:postgres}
password: ${POSTGRES_PASSWORD:postgres}
jpa:
hibernate:
ddl-auto: ${JPA_DDL_AUTO:update}
show-sql: ${JPA_SHOW_SQL:true}
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
format_sql: true
external:
claude-ai:
api-key: ${CLAUDE_AI_API_KEY:your-claude-api-key}
base-url: ${CLAUDE_AI_BASE_URL:https://api.anthropic.com}
model: ${CLAUDE_AI_MODEL:claude-3-sonnet-20240229}
max-tokens: ${CLAUDE_AI_MAX_TOKENS:4000}
springdoc:
swagger-ui:
path: /swagger-ui.html
operations-sorter: method
api-docs:
path: /api-docs
logging:
level:
com.won.smarketing.content: ${LOG_LEVEL:DEBUG}

View File

@ -1,7 +0,0 @@
dependencies {
implementation project(':common')
}
bootJar {
archiveFileName = "member-service.jar"
}

View File

@ -1,74 +0,0 @@
package com.won.smarketing.member.controller;
import com.won.smarketing.common.dto.ApiResponse;
import com.won.smarketing.member.dto.DuplicateCheckResponse;
import com.won.smarketing.member.dto.PasswordValidationRequest;
import com.won.smarketing.member.dto.RegisterRequest;
import com.won.smarketing.member.dto.ValidationResponse;
import com.won.smarketing.member.service.MemberService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
/**
* 회원 관리를 위한 REST API 컨트롤러
* 회원가입, ID 중복 확인, 패스워드 유효성 검증 기능 제공
*/
@Tag(name = "회원 관리", description = "회원가입 및 회원 정보 관리 API")
@RestController
@RequestMapping("/api/member")
@RequiredArgsConstructor
public class MemberController {
private final MemberService memberService;
/**
* 회원가입 처리
*
* @param request 회원가입 요청 정보
* @return 회원가입 성공/실패 응답
*/
@Operation(summary = "회원가입", description = "새로운 회원을 등록합니다.")
@PostMapping("/register")
public ResponseEntity<ApiResponse<Void>> register(@Valid @RequestBody RegisterRequest request) {
memberService.register(request);
return ResponseEntity.ok(ApiResponse.success(null, "회원가입이 완료되었습니다."));
}
/**
* ID 중복 확인
*
* @param userId 확인할 사용자 ID
* @return 중복 여부 응답
*/
@Operation(summary = "ID 중복 확인", description = "사용자 ID의 중복 여부를 확인합니다.")
@GetMapping("/check-duplicate")
public ResponseEntity<ApiResponse<DuplicateCheckResponse>> checkDuplicate(
@Parameter(description = "확인할 사용자 ID", required = true)
@RequestParam String userId) {
boolean isDuplicate = memberService.checkDuplicate(userId);
DuplicateCheckResponse response = DuplicateCheckResponse.builder()
.isDuplicate(isDuplicate)
.message(isDuplicate ? "이미 사용 중인 ID입니다." : "사용 가능한 ID입니다.")
.build();
return ResponseEntity.ok(ApiResponse.success(response));
}
/**
* 패스워드 유효성 검증
*
* @param request 패스워드 유효성 검증 요청
* @return 유효성 검증 결과
*/
@Operation(summary = "패스워드 유효성 검증", description = "패스워드가 보안 규칙을 만족하는지 확인합니다.")
@PostMapping("/validate-password")
public ResponseEntity<ApiResponse<ValidationResponse>> validatePassword(@Valid @RequestBody PasswordValidationRequest request) {
ValidationResponse response = memberService.validatePassword(request.getPassword());
return ResponseEntity.ok(ApiResponse.success(response));
}
}

View File

@ -1,25 +0,0 @@
package com.won.smarketing.member.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* ID 중복 확인 응답 DTO
* 사용자 ID 중복 여부 확인 결과
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "ID 중복 확인 응답")
public class DuplicateCheckResponse {
@Schema(description = "중복 여부", example = "false")
private boolean isDuplicate;
@Schema(description = "확인 결과 메시지", example = "사용 가능한 ID입니다.")
private String message;
}

View File

@ -1,28 +0,0 @@
package com.won.smarketing.member.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 로그인 응답 DTO
* 로그인 성공 반환되는 JWT 토큰 정보
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "로그인 응답 정보")
public class LoginResponse {
@Schema(description = "Access Token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")
private String accessToken;
@Schema(description = "Refresh Token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")
private String refreshToken;
@Schema(description = "토큰 만료 시간 (밀리초)", example = "900000")
private long expiresIn;
}

View File

@ -1,49 +0,0 @@
package com.won.smarketing.member.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.validation.constraints.Email;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;
/**
* 회원가입 요청 DTO
* 회원가입 필요한 정보를 담는 데이터 전송 객체
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "회원가입 요청 정보")
public class RegisterRequest {
@Schema(description = "사용자 ID", example = "testuser", required = true)
@NotBlank(message = "사용자 ID는 필수입니다.")
@Size(min = 4, max = 20, message = "사용자 ID는 4자 이상 20자 이하여야 합니다.")
@Pattern(regexp = "^[a-zA-Z0-9]+$", message = "사용자 ID는 영문과 숫자만 가능합니다.")
private String userId;
@Schema(description = "패스워드", example = "password123!", required = true)
@NotBlank(message = "패스워드는 필수입니다.")
private String password;
@Schema(description = "이름", example = "홍길동", required = true)
@NotBlank(message = "이름은 필수입니다.")
@Size(max = 100, message = "이름은 100자 이하여야 합니다.")
private String name;
@Schema(description = "사업자 번호", example = "123-45-67890", required = true)
@NotBlank(message = "사업자 번호는 필수입니다.")
@Pattern(regexp = "^\\d{3}-\\d{2}-\\d{5}$", message = "사업자 번호 형식이 올바르지 않습니다.")
private String businessNumber;
@Schema(description = "이메일", example = "test@example.com", required = true)
@NotBlank(message = "이메일은 필수입니다.")
@Email(message = "올바른 이메일 형식이 아닙니다.")
private String email;
}

View File

@ -1,28 +0,0 @@
package com.won.smarketing.member.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 토큰 응답 DTO
* 토큰 갱신 반환되는 새로운 JWT 토큰 정보
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "토큰 응답 정보")
public class TokenResponse {
@Schema(description = "새로운 Access Token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")
private String accessToken;
@Schema(description = "새로운 Refresh Token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...")
private String refreshToken;
@Schema(description = "토큰 만료 시간 (밀리초)", example = "900000")
private long expiresIn;
}

View File

@ -1,30 +0,0 @@
package com.won.smarketing.member.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 유효성 검증 응답 DTO
* 패스워드 유효성 검증 결과 정보
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "유효성 검증 응답")
public class ValidationResponse {
@Schema(description = "유효성 여부", example = "true")
private boolean isValid;
@Schema(description = "검증 결과 메시지", example = "유효한 패스워드입니다.")
private String message;
@Schema(description = "오류 목록")
private List<String> errors;
}

View File

@ -1,87 +0,0 @@
package com.won.smarketing.member.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.LocalDateTime;
/**
* 회원 정보를 나타내는 엔티티
* 사용자 ID, 패스워드, 이름, 사업자 번호, 이메일 정보 저장
*/
@Entity
@Table(name = "members")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class Member {
/**
* 회원 고유 식별자
*/
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* 사용자 ID (로그인용)
*/
@Column(name = "user_id", unique = true, nullable = false, length = 50)
private String userId;
/**
* 암호화된 패스워드
*/
@Column(name = "password", nullable = false)
private String password;
/**
* 회원 이름
*/
@Column(name = "name", nullable = false, length = 100)
private String name;
/**
* 사업자 번호
*/
@Column(name = "business_number", unique = true, nullable = false, length = 20)
private String businessNumber;
/**
* 이메일 주소
*/
@Column(name = "email", unique = true, nullable = false)
private String email;
/**
* 회원 생성 시각
*/
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
/**
* 회원 정보 수정 시각
*/
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
/**
* 엔티티 저장 실행되는 메서드
* 생성 시각과 수정 시각을 현재 시각으로 설정
*/
@PrePersist
protected void onCreate() {
createdAt = LocalDateTime.now();
updatedAt = LocalDateTime.now();
}
/**
* 엔티티 업데이트 실행되는 메서드
* 수정 시각을 현재 시각으로 갱신
*/
@PreUpdate
protected void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@ -1,131 +0,0 @@
package com.won.smarketing.member.service;
import com.won.smarketing.common.exception.BusinessException;
import com.won.smarketing.common.exception.ErrorCode;
import com.won.smarketing.common.security.JwtTokenProvider;
import com.won.smarketing.member.dto.LoginRequest;
import com.won.smarketing.member.dto.LoginResponse;
import com.won.smarketing.member.dto.TokenResponse;
import com.won.smarketing.member.entity.Member;
import com.won.smarketing.member.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.concurrent.TimeUnit;
/**
* 인증/인가 서비스 구현체
* 로그인, 로그아웃, 토큰 갱신 기능 구현
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AuthServiceImpl implements AuthService {
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
private final JwtTokenProvider jwtTokenProvider;
private final RedisTemplate<String, String> redisTemplate;
private static final String REFRESH_TOKEN_PREFIX = "refresh_token:";
/**
* 로그인 인증 처리
*
* @param request 로그인 요청 정보
* @return JWT 토큰 정보
*/
@Override
@Transactional
public LoginResponse login(LoginRequest request) {
// 사용자 조회
Member member = memberRepository.findByUserId(request.getUserId())
.orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND));
// 패스워드 검증
if (!passwordEncoder.matches(request.getPassword(), member.getPassword())) {
throw new BusinessException(ErrorCode.INVALID_PASSWORD);
}
// JWT 토큰 생성
String accessToken = jwtTokenProvider.generateAccessToken(member.getUserId());
String refreshToken = jwtTokenProvider.generateRefreshToken(member.getUserId());
long expiresIn = jwtTokenProvider.getAccessTokenValidityTime();
// Refresh Token을 Redis에 저장
String refreshTokenKey = REFRESH_TOKEN_PREFIX + member.getUserId();
redisTemplate.opsForValue().set(refreshTokenKey, refreshToken,
jwtTokenProvider.getRefreshTokenValidityTime(), TimeUnit.MILLISECONDS);
return LoginResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.expiresIn(expiresIn)
.build();
}
/**
* 로그아웃 처리
*
* @param refreshToken 무효화할 Refresh Token
*/
@Override
@Transactional
public void logout(String refreshToken) {
// 토큰에서 사용자 ID 추출
String userId = jwtTokenProvider.getUserIdFromToken(refreshToken);
// Redis에서 Refresh Token 삭제
String refreshTokenKey = REFRESH_TOKEN_PREFIX + userId;
redisTemplate.delete(refreshTokenKey);
}
/**
* 토큰 갱신 처리
*
* @param refreshToken 갱신에 사용할 Refresh Token
* @return 새로운 JWT 토큰 정보
*/
@Override
@Transactional
public TokenResponse refresh(String refreshToken) {
// Refresh Token 유효성 검증
if (!jwtTokenProvider.validateToken(refreshToken)) {
throw new BusinessException(ErrorCode.INVALID_TOKEN);
}
// 토큰에서 사용자 ID 추출
String userId = jwtTokenProvider.getUserIdFromToken(refreshToken);
// Redis에서 저장된 Refresh Token 확인
String refreshTokenKey = REFRESH_TOKEN_PREFIX + userId;
String storedRefreshToken = redisTemplate.opsForValue().get(refreshTokenKey);
if (storedRefreshToken == null || !storedRefreshToken.equals(refreshToken)) {
throw new BusinessException(ErrorCode.INVALID_TOKEN);
}
// 사용자 존재 여부 확인
Member member = memberRepository.findByUserId(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND));
// 새로운 토큰 생성
String newAccessToken = jwtTokenProvider.generateAccessToken(member.getUserId());
String newRefreshToken = jwtTokenProvider.generateRefreshToken(member.getUserId());
long expiresIn = jwtTokenProvider.getAccessTokenValidityTime();
// 기존 Refresh Token 삭제 새로운 토큰 저장
redisTemplate.delete(refreshTokenKey);
redisTemplate.opsForValue().set(refreshTokenKey, newRefreshToken,
jwtTokenProvider.getRefreshTokenValidityTime(), TimeUnit.MILLISECONDS);
return TokenResponse.builder()
.accessToken(newAccessToken)
.refreshToken(newRefreshToken)
.expiresIn(expiresIn)
.build();
}
}

View File

@ -1,115 +0,0 @@
package com.won.smarketing.member.service;
import com.won.smarketing.common.exception.BusinessException;
import com.won.smarketing.common.exception.ErrorCode;
import com.won.smarketing.member.dto.RegisterRequest;
import com.won.smarketing.member.dto.ValidationResponse;
import com.won.smarketing.member.entity.Member;
import com.won.smarketing.member.repository.MemberRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;
/**
* 회원 관리 서비스 구현체
* 회원가입, 중복 확인, 패스워드 유효성 검증 기능 구현
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberServiceImpl implements MemberService {
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
// 패스워드 정규식: 영문, 숫자, 특수문자 각각 최소 1개 포함, 8자 이상
private static final Pattern PASSWORD_PATTERN = Pattern.compile(
"^(?=.*[a-zA-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]{8,}$"
);
/**
* 회원가입 처리
*
* @param request 회원가입 요청 정보
*/
@Override
@Transactional
public void register(RegisterRequest request) {
// 중복 ID 확인
if (memberRepository.existsByUserId(request.getUserId())) {
throw new BusinessException(ErrorCode.DUPLICATE_MEMBER_ID);
}
// 이메일 중복 확인
if (memberRepository.existsByEmail(request.getEmail())) {
throw new BusinessException(ErrorCode.DUPLICATE_EMAIL);
}
// 사업자 번호 중복 확인
if (memberRepository.existsByBusinessNumber(request.getBusinessNumber())) {
throw new BusinessException(ErrorCode.DUPLICATE_BUSINESS_NUMBER);
}
// 패스워드 암호화
String encodedPassword = passwordEncoder.encode(request.getPassword());
// 회원 엔티티 생성 저장
Member member = Member.builder()
.userId(request.getUserId())
.password(encodedPassword)
.name(request.getName())
.businessNumber(request.getBusinessNumber())
.email(request.getEmail())
.build();
memberRepository.save(member);
}
/**
* 사용자 ID 중복 확인
*
* @param userId 확인할 사용자 ID
* @return 중복 여부 (true: 중복, false: 사용 가능)
*/
@Override
public boolean checkDuplicate(String userId) {
return memberRepository.existsByUserId(userId);
}
/**
* 패스워드 유효성 검증
*
* @param password 검증할 패스워드
* @return 유효성 검증 결과
*/
@Override
public ValidationResponse validatePassword(String password) {
List<String> errors = new ArrayList<>();
boolean isValid = true;
// 길이 검증 (8자 이상)
if (password.length() < 8) {
errors.add("패스워드는 8자 이상이어야 합니다.");
isValid = false;
}
// 패턴 검증 (영문, 숫자, 특수문자 포함)
if (!PASSWORD_PATTERN.matcher(password).matches()) {
errors.add("패스워드는 영문, 숫자, 특수문자를 각각 최소 1개씩 포함해야 합니다.");
isValid = false;
}
String message = isValid ? "유효한 패스워드입니다." : "패스워드가 보안 규칙을 만족하지 않습니다.";
return ValidationResponse.builder()
.isValid(isValid)
.message(message)
.errors(errors)
.build();
}
}

View File

@ -1,42 +0,0 @@
server:
port: ${SERVER_PORT:8081}
servlet:
context-path: /
spring:
application:
name: member-service
datasource:
url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:memberdb}
username: ${POSTGRES_USER:postgres}
password: ${POSTGRES_PASSWORD:postgres}
jpa:
hibernate:
ddl-auto: ${JPA_DDL_AUTO:update}
show-sql: ${JPA_SHOW_SQL:true}
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
format_sql: true
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
timeout: 2000ms
jwt:
secret-key: ${JWT_SECRET_KEY:mySecretKeyForJWTTokenGenerationThatShouldBeVeryLongAndSecure}
access-token-validity: ${JWT_ACCESS_TOKEN_VALIDITY:900000}
refresh-token-validity: ${JWT_REFRESH_TOKEN_VALIDITY:86400000}
springdoc:
swagger-ui:
path: /swagger-ui.html
operations-sorter: method
api-docs:
path: /api-docs
logging:
level:
com.won.smarketing.member: ${LOG_LEVEL:DEBUG}

23
smarketing-ai/.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
# Python 가상환경
venv/
env/
ENV/
.venv/
.env/
# Python 캐시
__pycache__/
*.py[cod]
*$py.class
*.so
# 환경 변수 파일
.env
.env.local
.env.*.local
# IDE 설정
.vscode/
.idea/
*.swp
*.swo

33
smarketing-ai/Dockerfile Normal file
View File

@ -0,0 +1,33 @@
# 1. Dockerfile에 한글 폰트 추가
FROM python:3.11-slim
WORKDIR /app
# 시스템 패키지 및 한글 폰트 설치
RUN apt-get update && apt-get install -y \
fonts-dejavu-core \
fonts-noto-cjk \
fonts-nanum \
wget \
&& rm -rf /var/lib/apt/lists/*
# 추가 한글 폰트 다운로드 (선택사항)
RUN mkdir -p /app/fonts && \
wget -O /app/fonts/NotoSansKR-Bold.ttf \
"https://fonts.gstatic.com/s/notosanskr/v13/PbykFmXiEBPT4ITbgNA5Cgm20xz64px_1hVWr0wuPNGmlQNMEfD4.ttf"
# Python 의존성 설치
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 애플리케이션 코드 복사
COPY . .
# 업로드 및 포스터 디렉토리 생성
RUN mkdir -p uploads/temp uploads/posters templates/poster_templates
# 포트 노출
EXPOSE 5000
# 애플리케이션 실행
CMD ["python", "app.py"]

301
smarketing-ai/app.py Normal file
View File

@ -0,0 +1,301 @@
"""
AI 마케팅 서비스 Flask 애플리케이션
점주를 위한 마케팅 콘텐츠 포스터 자동 생성 서비스
"""
from flask import Flask, request, jsonify
from flask_cors import CORS
from werkzeug.utils import secure_filename
import os
from datetime import datetime
import traceback
from config.config import Config
from services.poster_service import PosterService
from services.sns_content_service import SnsContentService
from models.request_models import ContentRequest, PosterRequest, SnsContentGetRequest, PosterContentGetRequest
from services.poster_service_v3 import PosterServiceV3
def create_app():
"""Flask 애플리케이션 팩토리"""
app = Flask(__name__)
app.config.from_object(Config)
# CORS 설정
CORS(app)
# 업로드 폴더 생성
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
os.makedirs(os.path.join(app.config['UPLOAD_FOLDER'], 'temp'), exist_ok=True)
os.makedirs('templates/poster_templates', exist_ok=True)
# 서비스 인스턴스 생성
poster_service = PosterService()
poster_service_v3 = PosterServiceV3()
sns_content_service = SnsContentService()
@app.route('/health', methods=['GET'])
def health_check():
"""헬스 체크 API"""
return jsonify({
'status': 'healthy',
'timestamp': datetime.now().isoformat(),
'service': 'AI Marketing Service'
})
# ===== 새로운 API 엔드포인트 =====
@app.route('/api/ai/sns', methods=['GET'])
def generate_sns_content():
"""
SNS 게시물 생성 API (새로운 요구사항)
Java 서버에서 JSON 형태로 요청받아 HTML 형식의 게시물 반환
"""
try:
# JSON 요청 데이터 검증
if not request.is_json:
return jsonify({'error': 'Content-Type은 application/json이어야 합니다.'}), 400
data = request.get_json()
if not data:
return jsonify({'error': '요청 데이터가 없습니다.'}), 400
# 필수 필드 검증
required_fields = ['title', 'category', 'contentType', 'platform', 'images']
for field in required_fields:
if field not in data:
return jsonify({'error': f'필수 필드가 누락되었습니다: {field}'}), 400
# 요청 모델 생성
sns_request = SnsContentGetRequest(
title=data.get('title'),
category=data.get('category'),
contentType=data.get('contentType'),
platform=data.get('platform'),
images=data.get('images', []),
requirement=data.get('requirement'),
toneAndManner=data.get('toneAndManner'),
emotionIntensity=data.get('emotionIntensity'),
menuName=data.get('menuName'),
eventName=data.get('eventName'),
startDate=data.get('startDate'),
endDate=data.get('endDate')
)
# SNS 콘텐츠 생성
result = sns_content_service.generate_sns_content(sns_request)
if result['success']:
return jsonify({'content': result['content']})
else:
return jsonify({'error': result['error']}), 500
except Exception as e:
app.logger.error(f"SNS 콘텐츠 생성 중 오류 발생: {str(e)}")
app.logger.error(traceback.format_exc())
return jsonify({'error': f'SNS 콘텐츠 생성 중 오류가 발생했습니다: {str(e)}'}), 500
@app.route('/api/ai/poster', methods=['GET'])
def generate_poster_content():
"""
홍보 포스터 생성 API
실제 제품 이미지를 포함한 분위기 배경 포스터 생성
"""
try:
# JSON 요청 데이터 검증
if not request.is_json:
return jsonify({'error': 'Content-Type은 application/json이어야 합니다.'}), 400
data = request.get_json()
if not data:
return jsonify({'error': '요청 데이터가 없습니다.'}), 400
# 필수 필드 검증
required_fields = ['title', 'category', 'contentType', 'images']
for field in required_fields:
if field not in data:
return jsonify({'error': f'필수 필드가 누락되었습니다: {field}'}), 400
# 날짜 변환 처리
start_date = None
end_date = None
if data.get('startDate'):
try:
from datetime import datetime
start_date = datetime.strptime(data['startDate'], '%Y-%m-%d').date()
except ValueError:
return jsonify({'error': 'startDate 형식이 올바르지 않습니다. YYYY-MM-DD 형식을 사용하세요.'}), 400
if data.get('endDate'):
try:
from datetime import datetime
end_date = datetime.strptime(data['endDate'], '%Y-%m-%d').date()
except ValueError:
return jsonify({'error': 'endDate 형식이 올바르지 않습니다. YYYY-MM-DD 형식을 사용하세요.'}), 400
# 요청 모델 생성
poster_request = PosterContentGetRequest(
title=data.get('title'),
category=data.get('category'),
contentType=data.get('contentType'),
images=data.get('images', []),
photoStyle=data.get('photoStyle'),
requirement=data.get('requirement'),
toneAndManner=data.get('toneAndManner'),
emotionIntensity=data.get('emotionIntensity'),
menuName=data.get('menuName'),
eventName=data.get('eventName'),
startDate=start_date,
endDate=end_date
)
# 포스터 생성 (V3 사용)
result = poster_service_v3.generate_poster(poster_request)
if result['success']:
return jsonify({
'content': result['content'],
'analysis': result.get('analysis', {})
})
else:
return jsonify({'error': result['error']}), 500
except Exception as e:
app.logger.error(f"포스터 생성 중 오류 발생: {str(e)}")
app.logger.error(traceback.format_exc())
return jsonify({'error': f'포스터 생성 중 오류가 발생했습니다: {str(e)}'}), 500
# ===== 기존 API 엔드포인트 (하위 호환성) =====
@app.route('/api/content/generate', methods=['POST'])
def generate_content():
"""
마케팅 콘텐츠 생성 API (기존)
점주가 입력한 정보를 바탕으로 플랫폼별 맞춤 게시글 생성
"""
try:
# 요청 데이터 검증
if not request.form:
return jsonify({'error': '요청 데이터가 없습니다.'}), 400
# 파일 업로드 처리
uploaded_files = []
if 'images' in request.files:
files = request.files.getlist('images')
for file in files:
if file and file.filename:
filename = secure_filename(file.filename)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
unique_filename = f"{timestamp}_{filename}"
file_path = os.path.join(app.config['UPLOAD_FOLDER'], 'temp', unique_filename)
file.save(file_path)
uploaded_files.append(file_path)
# 요청 모델 생성
content_request = ContentRequest(
category=request.form.get('category', '음식'),
platform=request.form.get('platform', '인스타그램'),
image_paths=uploaded_files,
start_time=request.form.get('start_time'),
end_time=request.form.get('end_time'),
store_name=request.form.get('store_name', ''),
additional_info=request.form.get('additional_info', '')
)
# 콘텐츠 생성
result = sns_content_service.generate_content(content_request)
# 임시 파일 정리
for file_path in uploaded_files:
try:
os.remove(file_path)
except OSError:
pass
return jsonify(result)
except Exception as e:
# 에러 발생 시 임시 파일 정리
for file_path in uploaded_files:
try:
os.remove(file_path)
except OSError:
pass
app.logger.error(f"콘텐츠 생성 중 오류 발생: {str(e)}")
app.logger.error(traceback.format_exc())
return jsonify({'error': f'콘텐츠 생성 중 오류가 발생했습니다: {str(e)}'}), 500
@app.route('/api/poster/generate', methods=['POST'])
def generate_poster():
"""
홍보 포스터 생성 API (기존)
점주가 입력한 정보를 바탕으로 시각적 홍보 포스터 생성
"""
try:
# 요청 데이터 검증
if not request.form:
return jsonify({'error': '요청 데이터가 없습니다.'}), 400
# 파일 업로드 처리
uploaded_files = []
if 'images' in request.files:
files = request.files.getlist('images')
for file in files:
if file and file.filename:
filename = secure_filename(file.filename)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
unique_filename = f"{timestamp}_{filename}"
file_path = os.path.join(app.config['UPLOAD_FOLDER'], 'temp', unique_filename)
file.save(file_path)
uploaded_files.append(file_path)
# 요청 모델 생성
poster_request = PosterRequest(
category=request.form.get('category', '음식'),
image_paths=uploaded_files,
start_time=request.form.get('start_time'),
end_time=request.form.get('end_time'),
store_name=request.form.get('store_name', ''),
event_title=request.form.get('event_title', ''),
discount_info=request.form.get('discount_info', ''),
additional_info=request.form.get('additional_info', '')
)
# 포스터 생성
result = poster_service.generate_poster(poster_request)
# 임시 파일 정리
for file_path in uploaded_files:
try:
os.remove(file_path)
except OSError:
pass
return jsonify(result)
except Exception as e:
# 에러 발생 시 임시 파일 정리
for file_path in uploaded_files:
try:
os.remove(file_path)
except OSError:
pass
app.logger.error(f"포스터 생성 중 오류 발생: {str(e)}")
app.logger.error(traceback.format_exc())
return jsonify({'error': f'포스터 생성 중 오류가 발생했습니다: {str(e)}'}), 500
@app.errorhandler(413)
def too_large(e):
"""파일 크기 초과 에러 처리"""
return jsonify({'error': '업로드된 파일이 너무 큽니다. (최대 16MB)'}), 413
@app.errorhandler(500)
def internal_error(error):
"""내부 서버 에러 처리"""
return jsonify({'error': '내부 서버 오류가 발생했습니다.'}), 500
return app
if __name__ == '__main__':
app = create_app()
app.run(host='0.0.0.0', port=5001, debug=True)

View File

@ -0,0 +1 @@
# Package initialization file

View File

@ -0,0 +1,30 @@
"""
Flask 애플리케이션 설정
환경변수를 통한 설정 관리
"""
import os
from dotenv import load_dotenv
load_dotenv()
class Config:
"""애플리케이션 설정 클래스"""
# Flask 기본 설정
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
# 파일 업로드 설정
UPLOAD_FOLDER = os.environ.get('UPLOAD_FOLDER') or 'uploads'
MAX_CONTENT_LENGTH = int(os.environ.get('MAX_CONTENT_LENGTH') or 16 * 1024 * 1024) # 16MB
# AI API 설정
CLAUDE_API_KEY = os.environ.get('CLAUDE_API_KEY')
OPENAI_API_KEY = os.environ.get('OPENAI_API_KEY')
# 지원되는 파일 확장자
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
# 템플릿 설정
POSTER_TEMPLATE_PATH = 'templates/poster_templates'
@staticmethod
def allowed_file(filename):
"""업로드 파일 확장자 검증"""
return '.' in filename and \
filename.rsplit('.', 1)[1].lower() in Config.ALLOWED_EXTENSIONS

View File

@ -0,0 +1 @@
# Package initialization file

View File

@ -0,0 +1,67 @@
"""
요청 모델 정의
API 요청 데이터 구조를 정의
"""
from dataclasses import dataclass
from typing import List, Optional
from datetime import date
@dataclass
class SnsContentGetRequest:
"""SNS 게시물 생성 요청 모델"""
title: str
category: str
contentType: str
platform: str
images: List[str] # 이미지 URL 리스트
requirement: Optional[str] = None
toneAndManner: Optional[str] = None
emotionIntensity: Optional[str] = None
menuName: Optional[str] = None
eventName: Optional[str] = None
startDate: Optional[date] = None # LocalDate -> date
endDate: Optional[date] = None # LocalDate -> date
@dataclass
class PosterContentGetRequest:
"""홍보 포스터 생성 요청 모델"""
title: str
category: str
contentType: str
images: List[str] # 이미지 URL 리스트
photoStyle: Optional[str] = None
requirement: Optional[str] = None
toneAndManner: Optional[str] = None
emotionIntensity: Optional[str] = None
menuName: Optional[str] = None
eventName: Optional[str] = None
startDate: Optional[date] = None # LocalDate -> date
endDate: Optional[date] = None # LocalDate -> date
# 기존 모델들은 유지
@dataclass
class ContentRequest:
"""마케팅 콘텐츠 생성 요청 모델 (기존)"""
category: str
platform: str
image_paths: List[str]
start_time: Optional[str] = None
end_time: Optional[str] = None
store_name: Optional[str] = None
additional_info: Optional[str] = None
@dataclass
class PosterRequest:
"""홍보 포스터 생성 요청 모델 (기존)"""
category: str
image_paths: List[str]
start_time: Optional[str] = None
end_time: Optional[str] = None
store_name: Optional[str] = None
event_title: Optional[str] = None
discount_info: Optional[str] = None
additional_info: Optional[str] = None

View File

@ -0,0 +1,8 @@
Flask==3.0.0
Flask-CORS==4.0.0
Pillow>=9.0.0
requests==2.31.0
anthropic>=0.25.0
openai>=1.12.0
python-dotenv==1.0.0
Werkzeug==3.0.1

View File

@ -0,0 +1 @@
# Package initialization file

View File

@ -0,0 +1,193 @@
"""
포스터 생성 서비스
OpenAI를 사용한 이미지 생성 (한글 프롬프트)
"""
import os
from typing import Dict, Any
from datetime import datetime
from utils.ai_client import AIClient
from utils.image_processor import ImageProcessor
from models.request_models import PosterContentGetRequest
class PosterService:
"""포스터 생성 서비스 클래스"""
def __init__(self):
"""서비스 초기화"""
self.ai_client = AIClient()
self.image_processor = ImageProcessor()
# 포토 스타일별 프롬프트
self.photo_styles = {
'미니멀': '미니멀하고 깔끔한 디자인, 단순함, 여백 활용',
'모던': '현대적이고 세련된 디자인, 깔끔한 레이아웃',
'빈티지': '빈티지 느낌, 레트로 스타일, 클래식한 색감',
'컬러풀': '다채로운 색상, 밝고 생동감 있는 컬러',
'우아한': '우아하고 고급스러운 느낌, 세련된 분위기',
'캐주얼': '친근하고 편안한 느낌, 접근하기 쉬운 디자인'
}
# 카테고리별 이미지 스타일
self.category_styles = {
'음식': '음식 사진, 먹음직스러운, 맛있어 보이는',
'매장': '레스토랑 인테리어, 아늑한 분위기',
'이벤트': '홍보용 디자인, 눈길을 끄는',
'메뉴': '메뉴 디자인, 정리된 레이아웃',
'할인': '세일 포스터, 할인 디자인'
}
# 톤앤매너별 디자인 스타일
self.tone_styles = {
'친근한': '따뜻하고 친근한 색감, 부드러운 느낌',
'정중한': '격식 있고 신뢰감 있는 디자인',
'재미있는': '밝고 유쾌한 분위기, 활기찬 색상',
'전문적인': '전문적이고 신뢰할 수 있는 디자인'
}
# 감정 강도별 디자인
self.emotion_designs = {
'약함': '은은하고 차분한 색감, 절제된 표현',
'보통': '적당히 활기찬 색상, 균형잡힌 디자인',
'강함': '강렬하고 임팩트 있는 색상, 역동적인 디자인'
}
def generate_poster(self, request: PosterContentGetRequest) -> Dict[str, Any]:
"""
포스터 생성 (OpenAI 이미지 URL 반환)
"""
try:
# 참조 이미지 분석 (있는 경우)
image_analysis = self._analyze_reference_images(request.images)
# 포스터 생성 프롬프트 생성
prompt = self._create_poster_prompt(request, image_analysis)
# OpenAI로 이미지 생성
image_url = self.ai_client.generate_image_with_openai(prompt, "1024x1024")
return {
'success': True,
'content': image_url
}
except Exception as e:
return {
'success': False,
'error': str(e)
}
def _analyze_reference_images(self, image_urls: list) -> Dict[str, Any]:
"""
참조 이미지들 분석
"""
if not image_urls:
return {'total_images': 0, 'results': []}
analysis_results = []
temp_files = []
try:
for image_url in image_urls:
# 이미지 다운로드
temp_path = self.ai_client.download_image_from_url(image_url)
if temp_path:
temp_files.append(temp_path)
try:
# 이미지 분석
image_description = self.ai_client.analyze_image(temp_path)
# 색상 분석
colors = self.image_processor.analyze_colors(temp_path, 3)
analysis_results.append({
'url': image_url,
'description': image_description,
'dominant_colors': colors
})
except Exception as e:
analysis_results.append({
'url': image_url,
'error': str(e)
})
return {
'total_images': len(image_urls),
'results': analysis_results
}
finally:
# 임시 파일 정리
for temp_file in temp_files:
try:
os.remove(temp_file)
except:
pass
def _create_poster_prompt(self, request: PosterContentGetRequest, image_analysis: Dict[str, Any]) -> str:
"""
포스터 생성을 위한 AI 프롬프트 생성 (한글)
"""
# 기본 스타일 설정
photo_style = self.photo_styles.get(request.photoStyle, '현대적이고 깔끔한 디자인')
category_style = self.category_styles.get(request.category, '홍보용 디자인')
tone_style = self.tone_styles.get(request.toneAndManner, '친근하고 따뜻한 느낌')
emotion_design = self.emotion_designs.get(request.emotionIntensity, '적당히 활기찬 디자인')
# 참조 이미지 설명
reference_descriptions = []
for result in image_analysis.get('results', []):
if 'description' in result:
reference_descriptions.append(result['description'])
# 색상 정보
color_info = ""
if image_analysis.get('results'):
colors = image_analysis['results'][0].get('dominant_colors', [])
if colors:
color_info = f"참조 색상 팔레트: {colors[:3]}을 활용한 조화로운 색감"
prompt = f"""
한국의 음식점/카페를 위한 전문적인 홍보 포스터를 디자인해주세요.
**메인 콘텐츠:**
- 제목: "{request.title}"
- 카테고리: {request.category}
- 콘텐츠 타입: {request.contentType}
**디자인 스타일 요구사항:**
- 포토 스타일: {photo_style}
- 카테고리 스타일: {category_style}
- 톤앤매너: {tone_style}
- 감정 강도: {emotion_design}
**메뉴 정보:**
- 메뉴명: {request.menuName or '없음'}
**이벤트 정보:**
- 이벤트명: {request.eventName or '특별 프로모션'}
- 시작일: {request.startDate or '지금'}
- 종료일: {request.endDate or '한정 기간'}
**특별 요구사항:**
{request.requirement or '눈길을 끄는 전문적인 디자인'}
**참조 이미지 설명:**
{chr(10).join(reference_descriptions) if reference_descriptions else '참조 이미지 없음'}
{color_info}
**디자인 가이드라인:**
- 한국 음식점/카페에 적합한 깔끔하고 현대적인 레이아웃
- 한글 텍스트 요소를 자연스럽게 포함
- 가독성이 좋은 전문적인 타이포그래피
- 명확한 대비로 읽기 쉽게 구성
- 소셜미디어 공유에 적합한 크기
- 저작권이 없는 오리지널 디자인
- 음식점에 어울리는 맛있어 보이는 색상 조합
- 고객의 시선을 끄는 매력적인 비주얼
고객들이 음식점을 방문하고 싶게 만드는 시각적으로 매력적인 포스터를 만들어주세요.
텍스트는 한글로, 전체적인 분위기는 한국적 감성에 맞게 디자인해주세요.
"""
return prompt

View File

@ -0,0 +1,382 @@
"""
하이브리드 포스터 생성 서비스
DALL-E: 텍스트 없는 아름다운 배경 생성
PIL: 완벽한 한글 텍스트 오버레이
"""
import os
from typing import Dict, Any
from datetime import datetime
from PIL import Image, ImageDraw, ImageFont, ImageEnhance
import requests
import io
from utils.ai_client import AIClient
from utils.image_processor import ImageProcessor
from models.request_models import PosterContentGetRequest
class PosterServiceV2:
"""하이브리드 포스터 생성 서비스"""
def __init__(self):
"""서비스 초기화"""
self.ai_client = AIClient()
self.image_processor = ImageProcessor()
def generate_poster(self, request: PosterContentGetRequest) -> Dict[str, Any]:
"""
하이브리드 포스터 생성
1. DALL-E로 텍스트 없는 배경 생성
2. PIL로 완벽한 한글 텍스트 오버레이
"""
try:
# 1. 참조 이미지 분석
image_analysis = self._analyze_reference_images(request.images)
# 2. DALL-E로 텍스트 없는 배경 생성
background_prompt = self._create_background_only_prompt(request, image_analysis)
background_url = self.ai_client.generate_image_with_openai(background_prompt, "1024x1024")
# 3. 배경 이미지 다운로드
background_image = self._download_and_load_image(background_url)
# 4. AI로 텍스트 컨텐츠 생성
text_content = self._generate_text_content(request)
# 5. PIL로 한글 텍스트 오버레이
final_poster = self._add_perfect_korean_text(background_image, text_content, request)
# 6. 최종 이미지 저장
poster_url = self._save_final_poster(final_poster)
return {
'success': True,
'content': poster_url
}
except Exception as e:
return {
'success': False,
'error': str(e)
}
def _create_background_only_prompt(self, request: PosterContentGetRequest, image_analysis: Dict[str, Any]) -> str:
"""텍스트 완전 제외 배경 전용 프롬프트"""
# 참조 이미지 설명
reference_descriptions = []
for result in image_analysis.get('results', []):
if 'description' in result:
reference_descriptions.append(result['description'])
prompt = f"""
Create a beautiful text-free background design for a Korean restaurant promotional poster.
ABSOLUTE REQUIREMENTS:
- NO TEXT, NO LETTERS, NO WORDS, NO CHARACTERS of any kind
- Pure visual background design only
- Professional Korean food business aesthetic
- Leave clear areas for text overlay (top 20% and bottom 30%)
DESIGN STYLE:
- Category: {request.category} themed design
- Photo Style: {request.photoStyle or 'modern'} aesthetic
- Mood: {request.toneAndManner or 'friendly'} atmosphere
- Intensity: {request.emotionIntensity or 'medium'} visual impact
VISUAL ELEMENTS TO INCLUDE:
- Korean traditional patterns or modern geometric designs
- Food-related visual elements (ingredients, cooking utensils, abstract food shapes)
- Warm, appetizing color palette
- Professional restaurant branding feel
- Clean, modern layout structure
REFERENCE CONTEXT:
{chr(10).join(reference_descriptions) if reference_descriptions else 'Clean, professional food business design'}
COMPOSITION:
- Central visual focus area
- Clear top section for main title
- Clear bottom section for details
- Balanced negative space
- High-end restaurant poster aesthetic
STRICTLY AVOID:
- Any form of text (Korean, English, numbers, symbols)
- Menu boards or signs with text
- Price displays
- Written content of any kind
- Typography elements
Create a premium, appetizing background that will make customers want to visit the restaurant.
Focus on visual appeal, color harmony, and professional food business branding.
"""
return prompt
def _download_and_load_image(self, image_url: str) -> Image.Image:
"""이미지 URL에서 PIL 이미지로 로드"""
response = requests.get(image_url, timeout=30)
response.raise_for_status()
return Image.open(io.BytesIO(response.content))
def _generate_text_content(self, request: PosterContentGetRequest) -> Dict[str, str]:
"""AI로 포스터 텍스트 컨텐츠 생성"""
prompt = f"""
한국 음식점 홍보 포스터용 텍스트를 생성해주세요.
포스터 정보:
- 제목: {request.title}
- 카테고리: {request.category}
- 메뉴명: {request.menuName or ''}
- 이벤트명: {request.eventName or ''}
- 시작일: {request.startDate or ''}
- 종료일: {request.endDate or ''}
다음 형식으로만 답변해주세요:
메인제목: [임팩트 있는 제목 8 이내]
서브제목: [설명 문구 15 이내]
기간정보: [기간 표시]
액션문구: [행동유도 8 이내]
"""
try:
ai_response = self.ai_client.generate_text(prompt, max_tokens=150)
return self._parse_text_content(ai_response, request)
except:
return self._create_fallback_content(request)
def _parse_text_content(self, ai_response: str, request: PosterContentGetRequest) -> Dict[str, str]:
"""AI 응답 파싱"""
content = {
'main_title': request.title[:8],
'sub_title': '',
'period_info': '',
'action_text': '지금 확인!'
}
lines = ai_response.split('\n')
for line in lines:
line = line.strip()
if '메인제목:' in line:
content['main_title'] = line.split('메인제목:')[1].strip()
elif '서브제목:' in line:
content['sub_title'] = line.split('서브제목:')[1].strip()
elif '기간정보:' in line:
content['period_info'] = line.split('기간정보:')[1].strip()
elif '액션문구:' in line:
content['action_text'] = line.split('액션문구:')[1].strip()
return content
def _create_fallback_content(self, request: PosterContentGetRequest) -> Dict[str, str]:
"""AI 실패시 기본 컨텐츠"""
return {
'main_title': request.title[:8] if request.title else '특별 이벤트',
'sub_title': request.eventName or request.menuName or '맛있는 음식',
'period_info': f"{request.startDate} ~ {request.endDate}" if request.startDate and request.endDate else '',
'action_text': '지금 방문!'
}
def _add_perfect_korean_text(self, background: Image.Image, content: Dict[str, str], request: PosterContentGetRequest) -> Image.Image:
"""완벽한 한글 텍스트 오버레이"""
# 배경 이미지 복사
poster = background.copy()
draw = ImageDraw.Draw(poster)
width, height = poster.size
# 한글 폰트 로드 (여러 경로 시도)
fonts = self._load_korean_fonts()
# 텍스트 색상 결정 (배경 분석 기반)
text_color = self._determine_text_color(background)
shadow_color = (0, 0, 0) if text_color == (255, 255, 255) else (255, 255, 255)
# 1. 메인 제목 (상단)
if content['main_title']:
self._draw_text_with_effects(
draw, content['main_title'],
fonts['title'], text_color, shadow_color,
width // 2, height * 0.15, 'center'
)
# 2. 서브 제목
if content['sub_title']:
self._draw_text_with_effects(
draw, content['sub_title'],
fonts['subtitle'], text_color, shadow_color,
width // 2, height * 0.75, 'center'
)
# 3. 기간 정보
if content['period_info']:
self._draw_text_with_effects(
draw, content['period_info'],
fonts['small'], text_color, shadow_color,
width // 2, height * 0.82, 'center'
)
# 4. 액션 문구 (강조 배경)
if content['action_text']:
self._draw_call_to_action(
draw, content['action_text'],
fonts['subtitle'], width, height
)
return poster
def _load_korean_fonts(self) -> Dict[str, ImageFont.FreeTypeFont]:
"""한글 폰트 로드 (여러 경로 시도)"""
font_paths = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
"/System/Library/Fonts/Arial.ttf", # macOS
"C:/Windows/Fonts/arial.ttf", # Windows
"/usr/share/fonts/TTF/arial.ttf" # Linux
]
fonts = {}
for font_path in font_paths:
try:
fonts['title'] = ImageFont.truetype(font_path, 60)
fonts['subtitle'] = ImageFont.truetype(font_path, 32)
fonts['small'] = ImageFont.truetype(font_path, 24)
break
except:
continue
# 폰트 로드 실패시 기본 폰트
if not fonts:
fonts = {
'title': ImageFont.load_default(),
'subtitle': ImageFont.load_default(),
'small': ImageFont.load_default()
}
return fonts
def _determine_text_color(self, image: Image.Image) -> tuple:
"""배경 이미지 분석하여 텍스트 색상 결정"""
# 이미지 상단과 하단의 평균 밝기 계산
top_region = image.crop((0, 0, image.width, image.height // 4))
bottom_region = image.crop((0, image.height * 3 // 4, image.width, image.height))
def get_brightness(img_region):
grayscale = img_region.convert('L')
pixels = list(grayscale.getdata())
return sum(pixels) / len(pixels)
top_brightness = get_brightness(top_region)
bottom_brightness = get_brightness(bottom_region)
avg_brightness = (top_brightness + bottom_brightness) / 2
# 밝으면 검은색, 어두우면 흰색 텍스트
return (50, 50, 50) if avg_brightness > 128 else (255, 255, 255)
def _draw_text_with_effects(self, draw, text, font, color, shadow_color, x, y, align='center'):
"""그림자 효과가 있는 텍스트 그리기"""
if not text:
return
# 텍스트 크기 계산
bbox = draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
# 위치 조정
if align == 'center':
x = x - text_width // 2
# 배경 박스 (가독성 향상)
padding = 10
box_coords = [
x - padding, y - padding,
x + text_width + padding, y + text_height + padding
]
draw.rectangle(box_coords, fill=(0, 0, 0, 180))
# 그림자 효과
shadow_offset = 2
draw.text((x + shadow_offset, y + shadow_offset), text, fill=shadow_color, font=font)
# 메인 텍스트
draw.text((x, y), text, fill=color, font=font)
def _draw_call_to_action(self, draw, text, font, width, height):
"""강조된 액션 버튼 스타일 텍스트"""
if not text:
return
# 버튼 위치 (하단 중앙)
button_y = height * 0.88
# 텍스트 크기
bbox = draw.textbbox((0, 0), text, font=font)
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
# 버튼 배경
button_width = text_width + 40
button_height = text_height + 20
button_x = (width - button_width) // 2
# 버튼 그리기
button_coords = [
button_x, button_y - 10,
button_x + button_width, button_y + button_height
]
draw.rounded_rectangle(button_coords, radius=25, fill=(255, 107, 107))
# 텍스트 그리기
text_x = (width - text_width) // 2
text_y = button_y + 5
draw.text((text_x, text_y), text, fill=(255, 255, 255), font=font)
def _save_final_poster(self, poster: Image.Image) -> str:
"""최종 포스터 저장"""
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f"hybrid_poster_{timestamp}.png"
filepath = os.path.join('uploads', 'temp', filename)
os.makedirs(os.path.dirname(filepath), exist_ok=True)
poster.save(filepath, 'PNG', quality=95)
return f"http://localhost:5001/uploads/temp/{filename}"
def _analyze_reference_images(self, image_urls: list) -> Dict[str, Any]:
"""참조 이미지 분석 (기존 코드와 동일)"""
if not image_urls:
return {'total_images': 0, 'results': []}
analysis_results = []
temp_files = []
try:
for image_url in image_urls:
temp_path = self.ai_client.download_image_from_url(image_url)
if temp_path:
temp_files.append(temp_path)
try:
image_description = self.ai_client.analyze_image(temp_path)
colors = self.image_processor.analyze_colors(temp_path, 3)
analysis_results.append({
'url': image_url,
'description': image_description,
'dominant_colors': colors
})
except Exception as e:
analysis_results.append({
'url': image_url,
'error': str(e)
})
return {
'total_images': len(image_urls),
'results': analysis_results
}
finally:
for temp_file in temp_files:
try:
os.remove(temp_file)
except:
pass

View File

@ -0,0 +1,204 @@
"""
포스터 생성 서비스 V3
OpenAI DALL-E를 사용한 이미지 생성 (메인 메뉴 이미지 1 + 프롬프트 예시 링크 10)
"""
import os
from typing import Dict, Any, List
from datetime import datetime
from utils.ai_client import AIClient
from utils.image_processor import ImageProcessor
from models.request_models import PosterContentGetRequest
class PosterServiceV3:
"""포스터 생성 서비스 V3 클래스"""
def __init__(self):
"""서비스 초기화"""
self.ai_client = AIClient()
self.image_processor = ImageProcessor()
# Azure Blob Storage 예시 이미지 링크 10개 (카페 음료 관련)
self.example_images = [
"https://stdigitalgarage02.blob.core.windows.net/ai-content/example1.png",
"https://stdigitalgarage02.blob.core.windows.net/ai-content/example2.png",
"https://stdigitalgarage02.blob.core.windows.net/ai-content/example3.png",
"https://stdigitalgarage02.blob.core.windows.net/ai-content/example4.png",
"https://stdigitalgarage02.blob.core.windows.net/ai-content/example5.png",
"https://stdigitalgarage02.blob.core.windows.net/ai-content/example6.png",
"https://stdigitalgarage02.blob.core.windows.net/ai-content/example7.png"
]
# 포토 스타일별 프롬프트
self.photo_styles = {
'미니멀': '미니멀하고 깔끔한 디자인, 단순함, 여백 활용',
'모던': '현대적이고 세련된 디자인, 깔끔한 레이아웃',
'빈티지': '빈티지 느낌, 레트로 스타일, 클래식한 색감',
'컬러풀': '다채로운 색상, 밝고 생동감 있는 컬러',
'우아한': '우아하고 고급스러운 느낌, 세련된 분위기',
'캐주얼': '친근하고 편안한 느낌, 접근하기 쉬운 디자인'
}
# 카테고리별 이미지 스타일
self.category_styles = {
'음식': '음식 사진, 먹음직스러운, 맛있어 보이는',
'매장': '레스토랑 인테리어, 아늑한 분위기',
'이벤트': '홍보용 디자인, 눈길을 끄는',
'메뉴': '메뉴 디자인, 정리된 레이아웃',
'할인': '세일 포스터, 할인 디자인',
'음료': '시원하고 상쾌한, 맛있어 보이는 음료'
}
# 톤앤매너별 디자인 스타일
self.tone_styles = {
'친근한': '따뜻하고 친근한 색감, 부드러운 느낌',
'정중한': '격식 있고 신뢰감 있는 디자인',
'재미있는': '밝고 유쾌한 분위기, 활기찬 색상',
'전문적인': '전문적이고 신뢰할 수 있는 디자인'
}
# 감정 강도별 디자인
self.emotion_designs = {
'약함': '은은하고 차분한 색감, 절제된 표현',
'보통': '적당히 활기찬 색상, 균형잡힌 디자인',
'강함': '강렬하고 임팩트 있는 색상, 역동적인 디자인'
}
def generate_poster(self, request: PosterContentGetRequest) -> Dict[str, Any]:
"""
포스터 생성 (메인 이미지 1 분석 + 예시 링크 10 프롬프트 제공)
"""
try:
# 메인 이미지 확인
if not request.images:
return {'success': False, 'error': '메인 메뉴 이미지가 제공되지 않았습니다.'}
main_image_url = request.images[0] # 첫 번째 이미지가 메인 메뉴
# 메인 이미지 분석
main_image_analysis = self._analyze_main_image(main_image_url)
# 포스터 생성 프롬프트 생성 (예시 링크 10개 포함)
prompt = self._create_poster_prompt_v3(request, main_image_analysis)
# OpenAI로 이미지 생성
image_url = self.ai_client.generate_image_with_openai(prompt, "1024x1024")
return {
'success': True,
'content': image_url,
'analysis': {
'main_image': main_image_analysis,
'example_images_used': len(self.example_images)
}
}
except Exception as e:
return {
'success': False,
'error': str(e)
}
def _analyze_main_image(self, image_url: str) -> Dict[str, Any]:
"""
메인 메뉴 이미지 분석
"""
temp_files = []
try:
# 이미지 다운로드
temp_path = self.ai_client.download_image_from_url(image_url)
if temp_path:
temp_files.append(temp_path)
# 이미지 분석
image_info = self.image_processor.get_image_info(temp_path)
image_description = self.ai_client.analyze_image(temp_path)
colors = self.image_processor.analyze_colors(temp_path, 5)
return {
'url': image_url,
'info': image_info,
'description': image_description,
'dominant_colors': colors,
'is_food': self.image_processor.is_food_image(temp_path)
}
else:
return {
'url': image_url,
'error': '이미지 다운로드 실패'
}
except Exception as e:
return {
'url': image_url,
'error': str(e)
}
finally:
# 임시 파일 정리
for temp_file in temp_files:
try:
os.remove(temp_file)
except:
pass
def _create_poster_prompt_v3(self, request: PosterContentGetRequest,
main_analysis: Dict[str, Any]) -> str:
"""
V3 포스터 생성을 위한 AI 프롬프트 생성 (한글, 글자 완전 제외, 메인 이미지 기반 + 예시 링크 10 포함)
"""
# 기본 스타일 설정
photo_style = self.photo_styles.get(request.photoStyle, '현대적이고 깔끔한 디자인')
category_style = self.category_styles.get(request.category, '홍보용 디자인')
tone_style = self.tone_styles.get(request.toneAndManner, '친근하고 따뜻한 느낌')
emotion_design = self.emotion_designs.get(request.emotionIntensity, '적당히 활기찬 디자인')
# 메인 이미지 정보 활용
main_description = main_analysis.get('description', '맛있는 음식')
main_colors = main_analysis.get('dominant_colors', [])
main_image_url = main_analysis.get('url', '')
image_info = main_analysis.get('info', {})
is_food = main_analysis.get('is_food', False)
# 이미지 크기 및 비율 정보
aspect_ratio = image_info.get('aspect_ratio', 1.0) if image_info else 1.0
image_orientation = "가로형" if aspect_ratio > 1.2 else "세로형" if aspect_ratio < 0.8 else "정사각형"
# 색상 정보를 텍스트로 변환
color_description = ""
if main_colors:
color_rgb = main_colors[:3] # 상위 3개 색상
color_description = f"주요 색상 RGB 값: {color_rgb}를 기반으로 한 조화로운 색감"
# 예시 이미지 링크들을 문자열로 변환
example_links = "\n".join([f"- {link}" for link in self.example_images])
prompt = f"""
메인 이미지 URL을 참조하여, "글이 없는" 심플한 카페 포스터를 디자인해주세요.
**핵심 기준 이미지:**
메인 이미지 URL: {main_image_url}
이미지 URL에 들어가 이미지를 다운로드 , 이미지를 그대로 반영한 홍보 포스터를 디자인해주세요.
심플한 배경이 중요합니다.
AI가 생성하지 않은 것처럼 현실적인 요소를 반영해주세요.
**절대 필수 조건:**
- 어떤 형태의 텍스트, 글자, 문자, 숫자도 절대 포함하지 !!!! - 가장 중요
- 위의 메인 이미지를 임의 변경 없이, 포스터의 중심 요소로 포함할
- 하나의 포스터만 생성해주세요
- 메인 이미지의 색감과 분위기를 살려서 심플한 포스터 디자인
- 메인 이미지가 돋보이도록 배경과 레이아웃 구성
- 확실하지도 않은 문자 절대 생성 x
**특별 요구사항:**
{request.requirement}
**반드시 제외할 요소:**
- 모든 형태의 텍스트 (한글, 영어, 숫자, 기호)
- 메뉴판, 가격표, 간판
- 글자가 적힌 모든 요소
- 브랜드 로고나 문자
"""
return prompt

View File

@ -0,0 +1,646 @@
"""
SNS 콘텐츠 생성 서비스 (플랫폼 특화 개선)
"""
import os
from typing import Dict, Any, List, Tuple
from datetime import datetime
from utils.ai_client import AIClient
from utils.image_processor import ImageProcessor
from models.request_models import SnsContentGetRequest
class SnsContentService:
def __init__(self):
"""서비스 초기화"""
self.ai_client = AIClient()
self.image_processor = ImageProcessor()
# 플랫폼별 콘텐츠 특성 정의 (대폭 개선)
self.platform_specs = {
'인스타그램': {
'max_length': 2200,
'hashtag_count': 15,
'style': '감성적이고 시각적',
'format': '짧은 문장, 해시태그 활용',
'content_structure': '후킹 문장 → 스토리텔링 → 행동 유도 → 해시태그',
'writing_tips': [
'첫 문장으로 관심 끌기',
'이모티콘을 적절히 활용',
'줄바꿈으로 가독성 높이기',
'개성 있는 말투 사용',
'팔로워와의 소통 유도'
],
'hashtag_strategy': [
'브랜딩 해시태그 포함',
'지역 기반 해시태그',
'트렌딩 해시태그 활용',
'음식 관련 인기 해시태그',
'감정 표현 해시태그'
],
'call_to_action': ['팔로우', '댓글', '저장', '공유', '방문']
},
'네이버 블로그': {
'max_length': 3000,
'hashtag_count': 10,
'style': '정보성과 친근함',
'format': '구조화된 내용, 상세 설명',
'content_structure': '제목 → 인트로 → 본문(구조화) → 마무리',
'writing_tips': [
'검색 키워드 자연스럽게 포함',
'단락별로 소제목 활용',
'구체적인 정보 제공',
'후기/리뷰 형식 활용',
'지역 정보 상세히 기술'
],
'seo_keywords': [
'맛집', '리뷰', '추천', '후기',
'메뉴', '가격', '위치', '분위기',
'데이트', '모임', '가족', '혼밥'
],
'call_to_action': ['방문', '예약', '문의', '공감', '이웃추가'],
'image_placement_strategy': [
'매장 외관 → 인테리어 → 메뉴판 → 음식 → 분위기',
'텍스트 2-3문장마다 이미지 배치',
'이미지 설명은 간결하고 매력적으로',
'마지막에 대표 이미지로 마무리'
]
}
}
# 톤앤매너별 스타일 (플랫폼별 세분화)
self.tone_styles = {
'친근한': {
'인스타그램': '반말, 친구같은 느낌, 이모티콘 많이 사용',
'네이버 블로그': '존댓말이지만 따뜻하고 친근한 어조'
},
'정중한': {
'인스타그램': '정중하지만 접근하기 쉬운 어조',
'네이버 블로그': '격식 있고 신뢰감 있는 리뷰 스타일'
},
'재미있는': {
'인스타그램': '유머러스하고 트렌디한 표현',
'네이버 블로그': '재미있는 에피소드가 포함된 후기'
},
'전문적인': {
'인스타그램': '전문성을 어필하되 딱딱하지 않게',
'네이버 블로그': '전문가 관점의 상세한 분석과 평가'
}
}
# 카테고리별 플랫폼 특화 키워드
self.category_keywords = {
'음식': {
'인스타그램': ['#맛스타그램', '#음식스타그램', '#먹스타그램', '#맛집', '#foodstagram'],
'네이버 블로그': ['맛집 리뷰', '음식 후기', '메뉴 추천', '맛집 탐방', '식당 정보']
},
'매장': {
'인스타그램': ['#카페스타그램', '#인테리어', '#분위기맛집', '#데이트장소'],
'네이버 블로그': ['카페 추천', '분위기 좋은 곳', '인테리어 구경', '모임장소']
},
'이벤트': {
'인스타그램': ['#이벤트', '#프로모션', '#할인', '#특가'],
'네이버 블로그': ['이벤트 소식', '할인 정보', '프로모션 안내', '특별 혜택']
}
}
# 감정 강도별 표현
self.emotion_levels = {
'약함': '은은하고 차분한 표현',
'보통': '적당히 활기찬 표현',
'강함': '매우 열정적이고 강렬한 표현'
}
# 이미지 타입 분류를 위한 키워드
self.image_type_keywords = {
'매장외관': ['외관', '건물', '간판', '입구', '외부'],
'인테리어': ['내부', '인테리어', '좌석', '테이블', '분위기', '장식'],
'메뉴판': ['메뉴', '가격', '메뉴판', '메뉴보드', 'menu'],
'음식': ['음식', '요리', '메뉴', '디저트', '음료', '플레이팅'],
'사람': ['사람', '고객', '직원', '사장', '요리사'],
'기타': ['기타', '일반', '전체']
}
def generate_sns_content(self, request: SnsContentGetRequest) -> Dict[str, Any]:
"""
SNS 콘텐츠 생성 (플랫폼별 특화)
"""
try:
# 이미지 다운로드 및 분석
image_analysis = self._analyze_images_from_urls(request.images)
# 네이버 블로그인 경우 이미지 배치 계획 생성
image_placement_plan = None
if request.platform == '네이버 블로그':
image_placement_plan = self._create_image_placement_plan(image_analysis, request)
# 플랫폼별 특화 프롬프트 생성
prompt = self._create_platform_specific_prompt(request, image_analysis, image_placement_plan)
# AI로 콘텐츠 생성
generated_content = self.ai_client.generate_text(prompt, max_tokens=1500)
# 플랫폼별 후처리
processed_content = self._post_process_content(generated_content, request)
# HTML 형식으로 포맷팅
html_content = self._format_to_html(processed_content, request, image_placement_plan)
result = {
'success': True,
'content': html_content
}
# 네이버 블로그인 경우 이미지 배치 가이드라인 추가
if request.platform == '네이버 블로그' and image_placement_plan:
result['image_placement_guide'] = image_placement_plan
return result
except Exception as e:
return {
'success': False,
'error': str(e)
}
def _analyze_images_from_urls(self, image_urls: list) -> Dict[str, Any]:
"""
URL에서 이미지를 다운로드하고 분석 (이미지 타입 분류 추가)
"""
analysis_results = []
temp_files = []
try:
for i, image_url in enumerate(image_urls):
# 이미지 다운로드
temp_path = self.ai_client.download_image_from_url(image_url)
if temp_path:
temp_files.append(temp_path)
# 이미지 분석
try:
image_info = self.image_processor.get_image_info(temp_path)
image_description = self.ai_client.analyze_image(temp_path)
# 이미지 타입 분류
image_type = self._classify_image_type(image_description)
analysis_results.append({
'index': i,
'url': image_url,
'info': image_info,
'description': image_description,
'type': image_type
})
except Exception as e:
analysis_results.append({
'index': i,
'url': image_url,
'error': str(e),
'type': '기타'
})
return {
'total_images': len(image_urls),
'results': analysis_results
}
finally:
# 임시 파일 정리
for temp_file in temp_files:
try:
os.remove(temp_file)
except:
pass
def _classify_image_type(self, description: str) -> str:
"""
이미지 설명을 바탕으로 이미지 타입 분류
"""
description_lower = description.lower()
for image_type, keywords in self.image_type_keywords.items():
for keyword in keywords:
if keyword in description_lower:
return image_type
return '기타'
def _create_image_placement_plan(self, image_analysis: Dict[str, Any], request: SnsContentGetRequest) -> Dict[
str, Any]:
"""
네이버 블로그용 이미지 배치 계획 생성
"""
images = image_analysis.get('results', [])
if not images:
return None
# 이미지 타입별 분류
categorized_images = {
'매장외관': [],
'인테리어': [],
'메뉴판': [],
'음식': [],
'사람': [],
'기타': []
}
for img in images:
img_type = img.get('type', '기타')
categorized_images[img_type].append(img)
# 블로그 구조에 따른 이미지 배치 계획
placement_plan = {
'structure': [
{
'section': '인트로',
'description': '첫인상과 방문 동기',
'recommended_images': [],
'placement_guide': '매장 외관이나 대표적인 음식 사진으로 시작'
},
{
'section': '매장 정보',
'description': '위치, 분위기, 인테리어 소개',
'recommended_images': [],
'placement_guide': '매장 외관 → 내부 인테리어 순서로 배치'
},
{
'section': '메뉴 소개',
'description': '주문한 메뉴와 상세 후기',
'recommended_images': [],
'placement_guide': '메뉴판 → 실제 음식 사진 순서로 배치'
},
{
'section': '총평',
'description': '재방문 의향과 추천 이유',
'recommended_images': [],
'placement_guide': '가장 매력적인 음식 사진이나 전체 분위기 사진'
}
],
'image_sequence': [],
'usage_guide': []
}
# 각 섹션에 적절한 이미지 배정
# 인트로: 매장외관 또는 대표 음식
if categorized_images['매장외관']:
placement_plan['structure'][0]['recommended_images'].extend(categorized_images['매장외관'][:1])
elif categorized_images['음식']:
placement_plan['structure'][0]['recommended_images'].extend(categorized_images['음식'][:1])
# 매장 정보: 외관 + 인테리어
placement_plan['structure'][1]['recommended_images'].extend(categorized_images['매장외관'])
placement_plan['structure'][1]['recommended_images'].extend(categorized_images['인테리어'])
# 메뉴 소개: 메뉴판 + 음식
placement_plan['structure'][2]['recommended_images'].extend(categorized_images['메뉴판'])
placement_plan['structure'][2]['recommended_images'].extend(categorized_images['음식'])
# 총평: 남은 음식 사진 또는 기타
remaining_food = [img for img in categorized_images['음식']
if img not in placement_plan['structure'][2]['recommended_images']]
placement_plan['structure'][3]['recommended_images'].extend(remaining_food[:1])
placement_plan['structure'][3]['recommended_images'].extend(categorized_images['기타'][:1])
# 전체 이미지 순서 생성
for section in placement_plan['structure']:
for img in section['recommended_images']:
if img not in placement_plan['image_sequence']:
placement_plan['image_sequence'].append(img)
# 사용 가이드 생성
placement_plan['usage_guide'] = [
"📸 이미지 배치 가이드라인:",
"1. 각 섹션마다 2-3문장의 설명 후 이미지 삽입",
"2. 이미지마다 간단한 설명 텍스트 추가",
"3. 음식 사진은 가장 맛있어 보이는 각도로 배치",
"4. 마지막에 전체적인 분위기를 보여주는 사진으로 마무리"
]
return placement_plan
def _create_platform_specific_prompt(self, request: SnsContentGetRequest, image_analysis: Dict[str, Any],
image_placement_plan: Dict[str, Any] = None) -> str:
"""
플랫폼별 특화 프롬프트 생성
"""
platform_spec = self.platform_specs.get(request.platform, self.platform_specs['인스타그램'])
tone_style = self.tone_styles.get(request.toneAndManner, {}).get(request.platform, '친근하고 자연스러운 어조')
# 이미지 설명 추출
image_descriptions = []
for result in image_analysis.get('results', []):
if 'description' in result:
image_descriptions.append(result['description'])
# 플랫폼별 특화 프롬프트 생성
if request.platform == '인스타그램':
return self._create_instagram_prompt(request, platform_spec, tone_style, image_descriptions)
elif request.platform == '네이버 블로그':
return self._create_naver_blog_prompt(request, platform_spec, tone_style, image_descriptions,
image_placement_plan)
else:
return self._create_instagram_prompt(request, platform_spec, tone_style, image_descriptions)
def _create_instagram_prompt(self, request: SnsContentGetRequest, platform_spec: dict, tone_style: str,
image_descriptions: list) -> str:
"""
인스타그램 특화 프롬프트
"""
category_hashtags = self.category_keywords.get(request.category, {}).get('인스타그램', [])
prompt = f"""
당신은 인스타그램 마케팅 전문가입니다. 소상공인 음식점을 위한 매력적인 인스타그램 게시물을 작성해주세요.
**🎯 콘텐츠 정보:**
- 제목: {request.title}
- 카테고리: {request.category}
- 콘텐츠 타입: {request.contentType}
- 메뉴명: {request.menuName or '특별 메뉴'}
- 이벤트: {request.eventName or '특별 이벤트'}
**📱 인스타그램 특화 요구사항:**
- 구조: {platform_spec['content_structure']}
- 최대 길이: {platform_spec['max_length']}
- 해시태그: {platform_spec['hashtag_count']} 내외
- 톤앤매너: {tone_style}
** 인스타그램 작성 가이드라인:**
{chr(10).join([f"- {tip}" for tip in platform_spec['writing_tips']])}
**📸 이미지 분석 결과:**
{chr(10).join(image_descriptions) if image_descriptions else '시각적으로 매력적인 음식/매장 이미지'}
**🏷 추천 해시태그 카테고리:**
- 기본 해시태그: {', '.join(category_hashtags[:5])}
- 브랜딩: #우리가게이름 (실제 가게명으로 대체)
- 지역: #강남맛집 #서울카페 (실제 위치로 대체)
- 감정: #행복한시간 #맛있다 #추천해요
**💡 콘텐츠 작성 지침:**
1. 문장은 반드시 관심을 끄는 후킹 문장으로 시작
2. 이모티콘을 적절히 활용하여 시각적 재미 추가
3. 스토리텔링을 통해 감정적 연결 유도
4. 명확한 행동 유도 문구 포함 (팔로우, 댓글, 저장, 방문 )
5. 줄바꿈을 활용하여 가독성 향상
6. 해시태그는 본문과 자연스럽게 연결되도록 배치
**특별 요구사항:**
{request.requirement or '고객의 관심을 끌고 방문을 유도하는 매력적인 게시물'}
인스타그램 사용자들이 "저장하고 싶다", "친구에게 공유하고 싶다"라고 생각할 만한 매력적인 게시물을 작성해주세요.
"""
return prompt
def _create_naver_blog_prompt(self, request: SnsContentGetRequest, platform_spec: dict, tone_style: str,
image_descriptions: list, image_placement_plan: Dict[str, Any]) -> str:
"""
네이버 블로그 특화 프롬프트 (이미지 배치 계획 포함)
"""
category_keywords = self.category_keywords.get(request.category, {}).get('네이버 블로그', [])
seo_keywords = platform_spec['seo_keywords']
# 이미지 배치 정보 추가
image_placement_info = ""
if image_placement_plan:
image_placement_info = f"""
**📸 이미지 배치 계획:**
{chr(10).join([f"- {section['section']}: {section['placement_guide']}" for section in image_placement_plan['structure']])}
**이미지 사용 순서:**
{chr(10).join([f"{i + 1}. {img.get('description', 'Image')} (타입: {img.get('type', '기타')})" for i, img in enumerate(image_placement_plan.get('image_sequence', []))])}
"""
prompt = f"""
당신은 네이버 블로그 맛집 리뷰 전문가입니다. 검색 최적화와 정보 제공을 중시하는 네이버 블로그 특성에 맞는 게시물을 작성해주세요.
**📝 콘텐츠 정보:**
- 제목: {request.title}
- 카테고리: {request.category}
- 콘텐츠 타입: {request.contentType}
- 메뉴명: {request.menuName or '대표 메뉴'}
- 이벤트: {request.eventName or '특별 이벤트'}
**🔍 네이버 블로그 특화 요구사항:**
- 구조: {platform_spec['content_structure']}
- 최대 길이: {platform_spec['max_length']}
- 톤앤매너: {tone_style}
- SEO 최적화 필수
**📚 블로그 작성 가이드라인:**
{chr(10).join([f"- {tip}" for tip in platform_spec['writing_tips']])}
**🖼 이미지 분석 결과:**
{chr(10).join(image_descriptions) if image_descriptions else '상세한 음식/매장 정보'}
{image_placement_info}
**🔑 SEO 키워드 (자연스럽게 포함할 ):**
- 필수 키워드: {', '.join(seo_keywords[:8])}
- 카테고리 키워드: {', '.join(category_keywords[:5])}
**📖 블로그 포스트 구조 (이미지 배치 포함):**
1. **인트로**: 방문 동기와 첫인상 + [IMAGE_1] 배치
2. **매장 정보**: 위치, 운영시간, 분위기 + [IMAGE_2, IMAGE_3] 배치
3. **메뉴 소개**: 주문한 메뉴와 상세 후기 + [IMAGE_4, IMAGE_5] 배치
4. **총평**: 재방문 의향과 추천 이유 + [IMAGE_6] 배치
**💡 콘텐츠 작성 지침:**
1. 검색자의 궁금증을 해결하는 정보 중심 작성
2. 구체적인 가격, 위치, 운영시간 실용 정보 포함
3. 개인적인 경험과 솔직한 후기 작성
4. 섹션마다 적절한 위치에 [IMAGE_X] 태그로 이미지 배치 위치 표시
5. 이미지마다 간단한 설명 문구 추가
6. 지역 정보와 접근성 정보 포함
**이미지 태그 사용법:**
- [IMAGE_1]: 번째 이미지 배치 위치
- [IMAGE_2]: 번째 이미지 배치 위치
- 이미지 태그 다음 줄에 이미지 설명 문구 작성
**특별 요구사항:**
{request.requirement or '유용한 정보를 제공하여 방문을 유도하는 신뢰성 있는 후기'}
네이버 검색에서 상위 노출되고, 실제로 도움이 되는 정보를 제공하는 블로그 포스트를 작성해주세요.
이미지 배치 위치를 [IMAGE_X] 태그로 명확히 표시해주세요.
"""
return prompt
def _post_process_content(self, content: str, request: SnsContentGetRequest) -> str:
"""
플랫폼별 후처리
"""
if request.platform == '인스타그램':
return self._post_process_instagram(content, request)
elif request.platform == '네이버 블로그':
return self._post_process_naver_blog(content, request)
return content
def _post_process_instagram(self, content: str, request: SnsContentGetRequest) -> str:
"""
인스타그램 콘텐츠 후처리
"""
import re
# 해시태그 개수 조정
hashtags = re.findall(r'#[\w가-힣]+', content)
if len(hashtags) > 15:
# 해시태그가 너무 많으면 중요도 순으로 15개만 유지
all_hashtags = ' '.join(hashtags[:15])
content = re.sub(r'#[\w가-힣]+', '', content)
content = content.strip() + '\n\n' + all_hashtags
# 이모티콘이 부족하면 추가
emoji_count = content.count('😊') + content.count('🍽️') + content.count('❤️') + content.count('')
if emoji_count < 3:
content = content.replace('!', '! 😊', 1)
return content
def _post_process_naver_blog(self, content: str, request: SnsContentGetRequest) -> str:
"""
네이버 블로그 콘텐츠 후처리
"""
# 구조화된 형태로 재구성
if '📍' not in content and '🏷️' not in content:
# 이모티콘 기반 구조화가 없으면 추가
lines = content.split('\n')
structured_content = []
for line in lines:
if '위치' in line or '주소' in line:
line = f"📍 {line}"
elif '가격' in line or '메뉴' in line:
line = f"🏷️ {line}"
elif '분위기' in line or '인테리어' in line:
line = f"🏠 {line}"
structured_content.append(line)
content = '\n'.join(structured_content)
return content
def _format_to_html(self, content: str, request: SnsContentGetRequest,
image_placement_plan: Dict[str, Any] = None) -> str:
"""
생성된 콘텐츠를 HTML 형식으로 포맷팅 (이미지 배치 포함)
"""
# 1. literal \n 문자열을 실제 줄바꿈으로 변환
content = content.replace('\\n', '\n')
# 2. 네이버 블로그인 경우 이미지 태그를 실제 이미지로 변환
if request.platform == '네이버 블로그' and image_placement_plan:
content = self._replace_image_tags_with_html(content, image_placement_plan, request.images)
# 3. 실제 줄바꿈을 <br> 태그로 변환
content = content.replace('\n', '<br>')
# 4. 추가 정리: \r, 여러 공백 정리
content = content.replace('\\r', '').replace('\r', '')
# 5. 여러 개의 <br> 태그를 하나로 정리
import re
content = re.sub(r'(<br>\s*){3,}', '<br><br>', content)
# 6. 해시태그를 파란색으로 스타일링
content = re.sub(r'(#[\w가-힣]+)', r'<span style="color: #1DA1F2; font-weight: bold;">\1</span>', content)
# 플랫폼별 헤더 스타일
platform_style = ""
if request.platform == '인스타그램':
platform_style = "background: linear-gradient(45deg, #f09433 0%,#e6683c 25%,#dc2743 50%,#cc2366 75%,#bc1888 100%);"
elif request.platform == '네이버 블로그':
platform_style = "background: linear-gradient(135deg, #1EC800 0%, #00B33C 100%);"
else:
platform_style = "background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);"
# 전체 HTML 구조
html_content = f"""
<div style="font-family: 'Noto Sans KR', Arial, sans-serif; line-height: 1.6; padding: 20px; max-width: 600px;">
<div style="{platform_style} color: white; padding: 15px; border-radius: 10px 10px 0 0; text-align: center;">
<h3 style="margin: 0; font-size: 18px;">{request.platform} 게시물</h3>
</div>
<div style="background: white; padding: 20px; border-radius: 0 0 10px 10px; border: 1px solid #e1e8ed;">
<div style="font-size: 16px; color: #333; line-height: 1.8;">
{content}
</div>
{self._add_metadata_html(request)}
</div>
</div>
"""
return html_content
def _replace_image_tags_with_html(self, content: str, image_placement_plan: Dict[str, Any],
image_urls: List[str]) -> str:
"""
네이버 블로그 콘텐츠의 [IMAGE_X] 태그를 실제 이미지 HTML로 변환
"""
import re
# [IMAGE_X] 패턴 찾기
image_tags = re.findall(r'\[IMAGE_(\d+)\]', content)
for tag in image_tags:
image_index = int(tag) - 1 # 1-based to 0-based
if image_index < len(image_urls):
image_url = image_urls[image_index]
# 이미지 배치 계획에서 해당 이미지 정보 찾기
image_info = None
for img in image_placement_plan.get('image_sequence', []):
if img.get('index') == image_index:
image_info = img
break
# 이미지 설명 생성
image_description = ""
if image_info:
description = image_info.get('description', '')
img_type = image_info.get('type', '기타')
if img_type == '음식':
image_description = f"😋 {description}"
elif img_type == '매장외관':
image_description = f"🏪 {description}"
elif img_type == '인테리어':
image_description = f"🏠 {description}"
elif img_type == '메뉴판':
image_description = f"📋 {description}"
else:
image_description = f"📸 {description}"
# HTML 이미지 태그로 변환
image_html = f"""
<div style="text-align: center; margin: 20px 0;">
<img src="{image_url}" alt="이미지" style="max-width: 100%; height: auto; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">
<div style="font-size: 14px; color: #666; margin-top: 8px; font-style: italic;">
{image_description}
</div>
</div>"""
# 콘텐츠에서 태그 교체
content = content.replace(f'[IMAGE_{tag}]', image_html)
return content
def _add_metadata_html(self, request: SnsContentGetRequest) -> str:
"""
메타데이터를 HTML에 추가
"""
metadata_html = '<div style="margin-top: 20px; padding-top: 15px; border-top: 1px solid #e1e8ed; font-size: 12px; color: #666;">'
if request.menuName:
metadata_html += f'<div><strong>메뉴:</strong> {request.menuName}</div>'
if request.eventName:
metadata_html += f'<div><strong>이벤트:</strong> {request.eventName}</div>'
if request.startDate and request.endDate:
metadata_html += f'<div><strong>기간:</strong> {request.startDate} ~ {request.endDate}</div>'
metadata_html += f'<div><strong>카테고리:</strong> {request.category}</div>'
metadata_html += f'<div><strong>플랫폼:</strong> {request.platform}</div>'
metadata_html += f'<div><strong>생성일:</strong> {datetime.now().strftime("%Y-%m-%d %H:%M")}</div>'
metadata_html += '</div>'
return metadata_html

View File

@ -0,0 +1 @@
# Package initialization file

View File

@ -0,0 +1,228 @@
"""
AI 클라이언트 유틸리티
Claude AI OpenAI API 호출을 담당
"""
import os
import base64
import requests
from typing import Optional, List
import anthropic
import openai
from PIL import Image
import io
class AIClient:
"""AI API 클라이언트 클래스"""
def __init__(self):
"""AI 클라이언트 초기화"""
self.claude_api_key = os.getenv('CLAUDE_API_KEY')
self.openai_api_key = os.getenv('OPENAI_API_KEY')
# Claude 클라이언트 초기화
if self.claude_api_key:
self.claude_client = anthropic.Anthropic(api_key=self.claude_api_key)
else:
self.claude_client = None
# OpenAI 클라이언트 초기화
if self.openai_api_key:
self.openai_client = openai.OpenAI(api_key=self.openai_api_key)
else:
self.openai_client = None
def download_image_from_url(self, image_url: str) -> str:
"""
URL에서 이미지를 다운로드하여 임시 파일로 저장
Args:
image_url: 다운로드할 이미지 URL
Returns:
임시 저장된 파일 경로
"""
try:
response = requests.get(image_url, timeout=30)
response.raise_for_status()
# 임시 파일로 저장
import tempfile
import uuid
file_extension = image_url.split('.')[-1] if '.' in image_url else 'jpg'
temp_filename = f"temp_{uuid.uuid4()}.{file_extension}"
temp_path = os.path.join('uploads', 'temp', temp_filename)
# 디렉토리 생성
os.makedirs(os.path.dirname(temp_path), exist_ok=True)
with open(temp_path, 'wb') as f:
f.write(response.content)
return temp_path
except Exception as e:
print(f"이미지 다운로드 실패 {image_url}: {e}")
return None
def generate_image_with_openai(self, prompt: str, size: str = "1024x1024") -> str:
"""
OpenAI DALL-E를 사용하여 이미지 생성
Args:
prompt: 이미지 생성 프롬프트
size: 이미지 크기 (1024x1024, 1792x1024, 1024x1792)
Returns:
생성된 이미지 URL
"""
try:
if not self.openai_client:
raise Exception("OpenAI API 키가 설정되지 않았습니다.")
response = self.openai_client.images.generate(
model="dall-e-3",
prompt=prompt,
size="1024x1024",
quality="hd", # 고품질 설정
style="vivid", # 또는 "natural"
n=1,
)
return response.data[0].url
except Exception as e:
print(f"OpenAI 이미지 생성 실패: {e}")
raise Exception(f"이미지 생성 중 오류가 발생했습니다: {str(e)}")
def generate_text(self, prompt: str, max_tokens: int = 1000) -> str:
"""
텍스트 생성 (Claude 우선, 실패시 OpenAI 사용)
"""
# Claude AI 시도
if self.claude_client:
try:
response = self.claude_client.messages.create(
model="claude-3-5-sonnet-20240620",
max_tokens=max_tokens,
messages=[
{"role": "user", "content": prompt}
]
)
return response.content[0].text
except Exception as e:
print(f"Claude AI 호출 실패: {e}")
# OpenAI 시도
if self.openai_client:
try:
response = self.openai_client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "user", "content": prompt}
],
max_tokens=max_tokens
)
return response.choices[0].message.content
except Exception as e:
print(f"OpenAI 호출 실패: {e}")
# 기본 응답
return self._generate_fallback_content(prompt)
def analyze_image(self, image_path: str) -> str:
"""
이미지 분석 설명 생성
"""
try:
# 이미지를 base64로 인코딩
image_base64 = self._encode_image_to_base64(image_path)
# Claude Vision API 시도
if self.claude_client:
try:
response = self.claude_client.messages.create(
model="claude-3-5-sonnet-20240620",
max_tokens=500,
messages=[
{
"role": "user",
"content": [
{
"type": "text",
"text": "이 이미지를 보고 음식점 마케팅에 활용할 수 있도록 매력적으로 설명해주세요. 음식이라면 맛있어 보이는 특징을, 매장이라면 분위기를, 이벤트라면 특별함을 강조해서 한국어로 50자 이내로 설명해주세요."
},
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/jpeg",
"data": image_base64
}
}
]
}
]
)
return response.content[0].text
except Exception as e:
print(f"Claude 이미지 분석 실패: {e}")
# OpenAI Vision API 시도
if self.openai_client:
try:
response = self.openai_client.chat.completions.create(
model="gpt-4o",
messages=[
{
"role": "user",
"content": [
{
"type": "text",
"text": "이 이미지를 보고 음식점 마케팅에 활용할 수 있도록 매력적으로 설명해주세요. 한국어로 50자 이내로 설명해주세요."
},
{
"type": "image_url",
"image_url": {
"url": f"data:image/jpeg;base64,{image_base64}"
}
}
]
}
],
max_tokens=300
)
return response.choices[0].message.content
except Exception as e:
print(f"OpenAI 이미지 분석 실패: {e}")
except Exception as e:
print(f"이미지 분석 전체 실패: {e}")
return "맛있고 매력적인 음식점의 특별한 순간"
def _encode_image_to_base64(self, image_path: str) -> str:
"""이미지 파일을 base64로 인코딩"""
with open(image_path, "rb") as image_file:
image = Image.open(image_file)
if image.width > 1024 or image.height > 1024:
image.thumbnail((1024, 1024), Image.Resampling.LANCZOS)
if image.mode == 'RGBA':
background = Image.new('RGB', image.size, (255, 255, 255))
background.paste(image, mask=image.split()[-1])
image = background
img_buffer = io.BytesIO()
image.save(img_buffer, format='JPEG', quality=85)
img_buffer.seek(0)
return base64.b64encode(img_buffer.getvalue()).decode('utf-8')
def _generate_fallback_content(self, prompt: str) -> str:
"""AI 서비스 실패시 기본 콘텐츠 생성"""
if "콘텐츠" in prompt or "게시글" in prompt:
return """안녕하세요! 오늘도 맛있는 하루 되세요 😊
우리 가게의 특별한 메뉴를 소개합니다!
정성껏 준비한 음식으로 여러분을 맞이하겠습니다.
많은 관심과 사랑 부탁드려요!"""
elif "포스터" in prompt:
return "특별한 이벤트\n지금 바로 확인하세요\n우리 가게에서 만나요\n놓치지 마세요!"
else:
return "안녕하세요! 우리 가게를 찾아주셔서 감사합니다."

View File

@ -0,0 +1,166 @@
"""
이미지 처리 유틸리티
이미지 분석, 변환, 최적화 기능 제공
"""
import os
from typing import Dict, Any, Tuple
from PIL import Image, ImageOps
import io
class ImageProcessor:
"""이미지 처리 클래스"""
def __init__(self):
"""이미지 프로세서 초기화"""
self.supported_formats = {'JPEG', 'PNG', 'WEBP', 'GIF'}
self.max_size = (2048, 2048) # 최대 크기
self.thumbnail_size = (400, 400) # 썸네일 크기
def get_image_info(self, image_path: str) -> Dict[str, Any]:
"""
이미지 기본 정보 추출
Args:
image_path: 이미지 파일 경로
Returns:
이미지 정보 딕셔너리
"""
try:
with Image.open(image_path) as image:
info = {
'filename': os.path.basename(image_path),
'format': image.format,
'mode': image.mode,
'size': image.size,
'width': image.width,
'height': image.height,
'file_size': os.path.getsize(image_path),
'aspect_ratio': round(image.width / image.height, 2) if image.height > 0 else 0
}
# 이미지 특성 분석
info['is_landscape'] = image.width > image.height
info['is_portrait'] = image.height > image.width
info['is_square'] = abs(image.width - image.height) < 50
return info
except Exception as e:
return {
'filename': os.path.basename(image_path),
'error': str(e)
}
def resize_image(self, image_path: str, target_size: Tuple[int, int],
maintain_aspect: bool = True) -> Image.Image:
"""
이미지 크기 조정
Args:
image_path: 원본 이미지 경로
target_size: 목표 크기 (width, height)
maintain_aspect: 종횡비 유지 여부
Returns:
리사이즈된 PIL 이미지
"""
try:
with Image.open(image_path) as image:
if maintain_aspect:
# 종횡비 유지하며 리사이즈
image.thumbnail(target_size, Image.Resampling.LANCZOS)
return image.copy()
else:
# 강제 리사이즈
return image.resize(target_size, Image.Resampling.LANCZOS)
except Exception as e:
raise Exception(f"이미지 리사이즈 실패: {str(e)}")
def optimize_image(self, image_path: str, quality: int = 85) -> bytes:
"""
이미지 최적화 (파일 크기 줄이기)
Args:
image_path: 원본 이미지 경로
quality: JPEG 품질 (1-100)
Returns:
최적화된 이미지 바이트
"""
try:
with Image.open(image_path) as image:
# RGBA를 RGB로 변환 (JPEG 저장을 위해)
if image.mode == 'RGBA':
background = Image.new('RGB', image.size, (255, 255, 255))
background.paste(image, mask=image.split()[-1])
image = background
# 크기가 너무 크면 줄이기
if image.width > self.max_size[0] or image.height > self.max_size[1]:
image.thumbnail(self.max_size, Image.Resampling.LANCZOS)
# 바이트 스트림으로 저장
img_buffer = io.BytesIO()
image.save(img_buffer, format='JPEG', quality=quality, optimize=True)
return img_buffer.getvalue()
except Exception as e:
raise Exception(f"이미지 최적화 실패: {str(e)}")
def create_thumbnail(self, image_path: str, size: Tuple[int, int] = None) -> Image.Image:
"""
썸네일 생성
Args:
image_path: 원본 이미지 경로
size: 썸네일 크기 (기본값: self.thumbnail_size)
Returns:
썸네일 PIL 이미지
"""
if size is None:
size = self.thumbnail_size
try:
with Image.open(image_path) as image:
# 정사각형 썸네일 생성
thumbnail = ImageOps.fit(image, size, Image.Resampling.LANCZOS)
return thumbnail
except Exception as e:
raise Exception(f"썸네일 생성 실패: {str(e)}")
def analyze_colors(self, image_path: str, num_colors: int = 5) -> list:
"""
이미지의 주요 색상 추출
Args:
image_path: 이미지 파일 경로
num_colors: 추출할 색상 개수
Returns:
주요 색상 리스트 [(R, G, B), ...]
"""
try:
with Image.open(image_path) as image:
# RGB로 변환
if image.mode != 'RGB':
image = image.convert('RGB')
# 이미지 크기 줄여서 처리 속도 향상
image.thumbnail((150, 150))
# 색상 히스토그램 생성
colors = image.getcolors(maxcolors=256*256*256)
if colors:
# 빈도순으로 정렬
colors.sort(key=lambda x: x[0], reverse=True)
# 상위 색상들 반환
dominant_colors = []
for count, color in colors[:num_colors]:
dominant_colors.append(color)
return dominant_colors
return [(128, 128, 128)] # 기본 회색
except Exception as e:
print(f"색상 분석 실패: {e}")
return [(128, 128, 128)] # 기본 회색
def is_food_image(self, image_path: str) -> bool:
"""
음식 이미지 여부 간단 판별
(실제로는 AI 모델이 필요하지만, 여기서는 기본적인 휴리스틱 사용)
Args:
image_path: 이미지 파일 경로
Returns:
음식 이미지 여부
"""
try:
# 파일명에서 키워드 확인
filename = os.path.basename(image_path).lower()
food_keywords = ['food', 'meal', 'dish', 'menu', '음식', '메뉴', '요리']
for keyword in food_keywords:
if keyword in filename:
return True
# 색상 분석으로 간단 판별 (음식은 따뜻한 색조가 많음)
colors = self.analyze_colors(image_path, 3)
warm_color_count = 0
for r, g, b in colors:
# 따뜻한 색상 (빨강, 노랑, 주황 계열) 확인
if r > 150 or (r > g and r > b):
warm_color_count += 1
return warm_color_count >= 2
except:
return False

View File

@ -0,0 +1,4 @@
dependencies {
implementation project(':common')
runtimeOnly 'com.mysql:mysql-connector-j'
}

View File

@ -0,0 +1,20 @@
package com.won.smarketing.recommend;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
@SpringBootApplication(scanBasePackages = {
"com.won.smarketing.recommend",
"com.won.smarketing.common"
})
@EnableJpaAuditing
@EnableJpaRepositories(basePackages = "com.won.smarketing.recommend.infrastructure.persistence")
@EnableCaching
public class AIRecommendServiceApplication {
public static void main(String[] args) {
SpringApplication.run(AIRecommendServiceApplication.class, args);
}
}

View File

@ -0,0 +1,101 @@
package com.won.smarketing.recommend.application.service;
import com.won.smarketing.common.exception.BusinessException;
import com.won.smarketing.common.exception.ErrorCode;
import com.won.smarketing.recommend.application.usecase.MarketingTipUseCase;
import com.won.smarketing.recommend.domain.model.MarketingTip;
import com.won.smarketing.recommend.domain.model.StoreData;
import com.won.smarketing.recommend.domain.repository.MarketingTipRepository;
import com.won.smarketing.recommend.domain.service.StoreDataProvider;
import com.won.smarketing.recommend.domain.service.AiTipGenerator;
import com.won.smarketing.recommend.presentation.dto.MarketingTipRequest;
import com.won.smarketing.recommend.presentation.dto.MarketingTipResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 마케팅 서비스 구현체
*/
@Slf4j
@Service
@RequiredArgsConstructor
@Transactional
public class MarketingTipService implements MarketingTipUseCase {
private final MarketingTipRepository marketingTipRepository;
private final StoreDataProvider storeDataProvider;
private final AiTipGenerator aiTipGenerator;
@Override
public MarketingTipResponse generateMarketingTips(MarketingTipRequest request) {
log.info("마케팅 팁 생성 시작: storeId={}", request.getStoreId());
try {
// 1. 매장 정보 조회
StoreData storeData = storeDataProvider.getStoreData(request.getStoreId());
log.debug("매장 정보 조회 완료: {}", storeData.getStoreName());
// 2. Python AI 서비스로 생성 (매장 정보 + 추가 요청사항 전달)
String aiGeneratedTip = aiTipGenerator.generateTip(storeData, request.getAdditionalRequirement());
log.debug("AI 팁 생성 완료: {}", aiGeneratedTip.substring(0, Math.min(50, aiGeneratedTip.length())));
// 3. 도메인 객체 생성 저장
MarketingTip marketingTip = MarketingTip.builder()
.storeId(request.getStoreId())
.tipContent(aiGeneratedTip)
.storeData(storeData)
.build();
MarketingTip savedTip = marketingTipRepository.save(marketingTip);
log.info("마케팅 팁 저장 완료: tipId={}", savedTip.getId().getValue());
return convertToResponse(savedTip);
} catch (Exception e) {
log.error("마케팅 팁 생성 중 오류: storeId={}", request.getStoreId(), e);
throw new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR);
}
}
@Override
@Transactional(readOnly = true)
@Cacheable(value = "marketingTipHistory", key = "#storeId + '_' + #pageable.pageNumber + '_' + #pageable.pageSize")
public Page<MarketingTipResponse> getMarketingTipHistory(Long storeId, Pageable pageable) {
log.info("마케팅 팁 이력 조회: storeId={}", storeId);
Page<MarketingTip> tips = marketingTipRepository.findByStoreIdOrderByCreatedAtDesc(storeId, pageable);
return tips.map(this::convertToResponse);
}
@Override
@Transactional(readOnly = true)
public MarketingTipResponse getMarketingTip(Long tipId) {
log.info("마케팅 팁 상세 조회: tipId={}", tipId);
MarketingTip marketingTip = marketingTipRepository.findById(tipId)
.orElseThrow(() -> new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR));
return convertToResponse(marketingTip);
}
private MarketingTipResponse convertToResponse(MarketingTip marketingTip) {
return MarketingTipResponse.builder()
.tipId(marketingTip.getId().getValue())
.storeId(marketingTip.getStoreId())
.storeName(marketingTip.getStoreData().getStoreName())
.tipContent(marketingTip.getTipContent())
.storeInfo(MarketingTipResponse.StoreInfo.builder()
.storeName(marketingTip.getStoreData().getStoreName())
.businessType(marketingTip.getStoreData().getBusinessType())
.location(marketingTip.getStoreData().getLocation())
.build())
.createdAt(marketingTip.getCreatedAt())
.build();
}
}

View File

@ -0,0 +1,27 @@
package com.won.smarketing.recommend.application.usecase;
import com.won.smarketing.recommend.presentation.dto.MarketingTipRequest;
import com.won.smarketing.recommend.presentation.dto.MarketingTipResponse;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
/**
* 마케팅 유즈케이스 인터페이스
*/
public interface MarketingTipUseCase {
/**
* AI 마케팅 생성
*/
MarketingTipResponse generateMarketingTips(MarketingTipRequest request);
/**
* 마케팅 이력 조회
*/
Page<MarketingTipResponse> getMarketingTipHistory(Long storeId, Pageable pageable);
/**
* 마케팅 상세 조회
*/
MarketingTipResponse getMarketingTip(Long tipId);
}

View File

@ -0,0 +1,13 @@
package com.won.smarketing.recommend.config;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Configuration;
/**
* 캐시 설정
*/
@Configuration
@EnableCaching
public class CacheConfig {
// 기본 Simple 캐시 사용
}

View File

@ -0,0 +1,12 @@
package com.won.smarketing.recommend.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
/**
* JPA 설정
*/
@Configuration
@EnableJpaRepositories
public class JpaConfig {
}

View File

@ -0,0 +1,29 @@
package com.won.smarketing.recommend.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.client.WebClient;
import io.netty.channel.ChannelOption;
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
import reactor.netty.http.client.HttpClient;
import java.time.Duration;
/**
* WebClient 설정 (간소화된 버전)
*/
@Configuration
public class WebClientConfig {
@Bean
public WebClient webClient() {
HttpClient httpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000)
.responseTimeout(Duration.ofMillis(5000));
return WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
.codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(1024 * 1024))
.build();
}
}

View File

@ -0,0 +1,33 @@
package com.won.smarketing.recommend.domain.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 마케팅 도메인 모델 (날씨 정보 제거)
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MarketingTip {
private TipId id;
private Long storeId;
private String tipContent;
private StoreData storeData;
private LocalDateTime createdAt;
public static MarketingTip create(Long storeId, String tipContent, StoreData storeData) {
return MarketingTip.builder()
.storeId(storeId)
.tipContent(tipContent)
.storeData(storeData)
.createdAt(LocalDateTime.now())
.build();
}
}

View File

@ -0,0 +1,19 @@
package com.won.smarketing.recommend.domain.model;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 매장 데이터 객체
*/
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class StoreData {
private String storeName;
private String businessType;
private String location;
}

View File

@ -0,0 +1,21 @@
package com.won.smarketing.recommend.domain.model;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* ID 객체
*/
@Getter
@EqualsAndHashCode
@NoArgsConstructor
@AllArgsConstructor
public class TipId {
private Long value;
public static TipId of(Long value) {
return new TipId(value);
}
}

View File

@ -0,0 +1,19 @@
package com.won.smarketing.recommend.domain.repository;
import com.won.smarketing.recommend.domain.model.MarketingTip;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import java.util.Optional;
/**
* 마케팅 레포지토리 인터페이스 (순수한 도메인 인터페이스)
*/
public interface MarketingTipRepository {
MarketingTip save(MarketingTip marketingTip);
Optional<MarketingTip> findById(Long tipId);
Page<MarketingTip> findByStoreIdOrderByCreatedAtDesc(Long storeId, Pageable pageable);
}

View File

@ -0,0 +1,18 @@
package com.won.smarketing.recommend.domain.service;
import com.won.smarketing.recommend.domain.model.StoreData;
/**
* AI 생성 도메인 서비스 인터페이스 (단순화)
*/
public interface AiTipGenerator {
/**
* Python AI 서비스를 통한 마케팅 생성
*
* @param storeData 매장 정보
* @param additionalRequirement 추가 요청사항
* @return AI가 생성한 마케팅
*/
String generateTip(StoreData storeData, String additionalRequirement);
}

View File

@ -4,15 +4,8 @@ import com.won.smarketing.recommend.domain.model.StoreData;
/** /**
* 매장 데이터 제공 도메인 서비스 인터페이스 * 매장 데이터 제공 도메인 서비스 인터페이스
* 외부 매장 서비스로부터 매장 정보 조회 기능 정의
*/ */
public interface StoreDataProvider { public interface StoreDataProvider {
/**
* 매장 ID로 매장 데이터 조회
*
* @param storeId 매장 ID
* @return 매장 데이터
*/
StoreData getStoreData(Long storeId); StoreData getStoreData(Long storeId);
} }

View File

@ -0,0 +1,138 @@
package com.won.smarketing.recommend.infrastructure.external;
import com.won.smarketing.recommend.domain.model.StoreData;
import com.won.smarketing.recommend.domain.service.AiTipGenerator;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; // 어노테이션이 누락되어 있었음
import org.springframework.web.reactive.function.client.WebClient;
import java.time.Duration;
import java.util.Map;
/**
* Python AI 생성 구현체 (날씨 정보 제거)
*/
@Slf4j
@Service // 추가된 어노테이션
@RequiredArgsConstructor
public class PythonAiTipGenerator implements AiTipGenerator {
private final WebClient webClient;
@Value("${external.python-ai-service.base-url}")
private String pythonAiServiceBaseUrl;
@Value("${external.python-ai-service.api-key}")
private String pythonAiServiceApiKey;
@Value("${external.python-ai-service.timeout}")
private int timeout;
@Override
public String generateTip(StoreData storeData, String additionalRequirement) {
try {
log.debug("Python AI 서비스 호출: store={}", storeData.getStoreName());
// Python AI 서비스 사용 가능 여부 확인
if (isPythonServiceAvailable()) {
return callPythonAiService(storeData, additionalRequirement);
} else {
log.warn("Python AI 서비스 사용 불가, Fallback 처리");
return createFallbackTip(storeData, additionalRequirement);
}
} catch (Exception e) {
log.error("Python AI 서비스 호출 실패, Fallback 처리: {}", e.getMessage());
return createFallbackTip(storeData, additionalRequirement);
}
}
private boolean isPythonServiceAvailable() {
return !pythonAiServiceApiKey.equals("dummy-key");
}
private String callPythonAiService(StoreData storeData, String additionalRequirement) {
try {
// Python AI 서비스로 전송할 데이터 (날씨 정보 제거, 매장 정보만 전달)
Map<String, Object> requestData = Map.of(
"store_name", storeData.getStoreName(),
"business_type", storeData.getBusinessType(),
"location", storeData.getLocation(),
"additional_requirement", additionalRequirement != null ? additionalRequirement : ""
);
log.debug("Python AI 서비스 요청 데이터: {}", requestData);
PythonAiResponse response = webClient
.post()
.uri(pythonAiServiceBaseUrl + "/api/v1/generate-marketing-tip")
.header("Authorization", "Bearer " + pythonAiServiceApiKey)
.header("Content-Type", "application/json")
.bodyValue(requestData)
.retrieve()
.bodyToMono(PythonAiResponse.class)
.timeout(Duration.ofMillis(timeout))
.block();
if (response != null && response.getTip() != null && !response.getTip().trim().isEmpty()) {
log.debug("Python AI 서비스 응답 성공: tip length={}", response.getTip().length());
return response.getTip();
}
} catch (Exception e) {
log.error("Python AI 서비스 실제 호출 실패: {}", e.getMessage());
}
return createFallbackTip(storeData, additionalRequirement);
}
/**
* 규칙 기반 Fallback 생성 (날씨 정보 없이 매장 정보만 활용)
*/
private String createFallbackTip(StoreData storeData, String additionalRequirement) {
String businessType = storeData.getBusinessType();
String storeName = storeData.getStoreName();
String location = storeData.getLocation();
// 추가 요청사항이 있는 경우 우선 반영
if (additionalRequirement != null && !additionalRequirement.trim().isEmpty()) {
return String.format("%s에서 %s를 중심으로 한 특별한 서비스로 고객들을 맞이해보세요!",
storeName, additionalRequirement);
}
// 업종별 기본 생성
if (businessType.contains("카페")) {
return String.format("%s만의 시그니처 음료와 디저트로 고객들에게 특별한 경험을 선사해보세요!", storeName);
} else if (businessType.contains("음식점") || businessType.contains("식당")) {
return String.format("%s의 대표 메뉴를 활용한 특별한 이벤트로 고객들의 관심을 끌어보세요!", storeName);
} else if (businessType.contains("베이커리") || businessType.contains("빵집")) {
return String.format("%s의 갓 구운 빵과 함께하는 따뜻한 서비스로 고객들의 마음을 사로잡아보세요!", storeName);
} else if (businessType.contains("치킨") || businessType.contains("튀김")) {
return String.format("%s의 바삭하고 맛있는 메뉴로 고객들에게 만족스러운 식사를 제공해보세요!", storeName);
}
// 지역별
if (location.contains("강남") || location.contains("서초")) {
return String.format("%s에서 트렌디하고 세련된 서비스로 젊은 고객층을 공략해보세요!", storeName);
} else if (location.contains("홍대") || location.contains("신촌")) {
return String.format("%s에서 활기차고 개성 있는 이벤트로 대학생들의 관심을 끌어보세요!", storeName);
}
// 기본
return String.format("%s만의 특별함을 살린 고객 맞춤 서비스로 단골 고객을 늘려보세요!", storeName);
}
private static class PythonAiResponse {
private String tip;
private String status;
private String message;
public String getTip() { return tip; }
public void setTip(String tip) { this.tip = tip; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
}
}

View File

@ -0,0 +1,124 @@
package com.won.smarketing.recommend.infrastructure.external;
import com.won.smarketing.common.exception.BusinessException;
import com.won.smarketing.common.exception.ErrorCode;
import com.won.smarketing.recommend.domain.model.StoreData;
import com.won.smarketing.recommend.domain.service.StoreDataProvider;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service; // 어노테이션이 누락되어 있었음
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import java.time.Duration;
/**
* 매장 API 데이터 제공자 구현체
*/
@Slf4j
@Service // 추가된 어노테이션
@RequiredArgsConstructor
public class StoreApiDataProvider implements StoreDataProvider {
private final WebClient webClient;
@Value("${external.store-service.base-url}")
private String storeServiceBaseUrl;
@Value("${external.store-service.timeout}")
private int timeout;
@Override
@Cacheable(value = "storeData", key = "#storeId")
public StoreData getStoreData(Long storeId) {
try {
log.debug("매장 정보 조회 시도: storeId={}", storeId);
// 외부 서비스 연결 시도, 실패 Mock 데이터 반환
if (isStoreServiceAvailable()) {
return callStoreService(storeId);
} else {
log.warn("매장 서비스 연결 불가, Mock 데이터 반환: storeId={}", storeId);
return createMockStoreData(storeId);
}
} catch (Exception e) {
log.error("매장 정보 조회 실패, Mock 데이터 반환: storeId={}", storeId, e);
return createMockStoreData(storeId);
}
}
private boolean isStoreServiceAvailable() {
return !storeServiceBaseUrl.equals("http://localhost:8082");
}
private StoreData callStoreService(Long storeId) {
try {
StoreApiResponse response = webClient
.get()
.uri(storeServiceBaseUrl + "/api/store/" + storeId)
.retrieve()
.bodyToMono(StoreApiResponse.class)
.timeout(Duration.ofMillis(timeout))
.block();
if (response != null && response.getData() != null) {
StoreApiResponse.StoreInfo storeInfo = response.getData();
return StoreData.builder()
.storeName(storeInfo.getStoreName())
.businessType(storeInfo.getBusinessType())
.location(storeInfo.getAddress())
.build();
}
} catch (WebClientResponseException e) {
if (e.getStatusCode().value() == 404) {
throw new BusinessException(ErrorCode.STORE_NOT_FOUND);
}
log.error("매장 서비스 호출 실패: {}", e.getMessage());
}
return createMockStoreData(storeId);
}
private StoreData createMockStoreData(Long storeId) {
return StoreData.builder()
.storeName("테스트 카페 " + storeId)
.businessType("카페")
.location("서울시 강남구")
.build();
}
private static class StoreApiResponse {
private int status;
private String message;
private StoreInfo data;
public int getStatus() { return status; }
public void setStatus(int status) { this.status = status; }
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
public StoreInfo getData() { return data; }
public void setData(StoreInfo data) { this.data = data; }
static class StoreInfo {
private Long storeId;
private String storeName;
private String businessType;
private String address;
private String phoneNumber;
public Long getStoreId() { return storeId; }
public void setStoreId(Long storeId) { this.storeId = storeId; }
public String getStoreName() { return storeName; }
public void setStoreName(String storeName) { this.storeName = storeName; }
public String getBusinessType() { return businessType; }
public void setBusinessType(String businessType) { this.businessType = businessType; }
public String getAddress() { return address; }
public void setAddress(String address) { this.address = address; }
public String getPhoneNumber() { return phoneNumber; }
public void setPhoneNumber(String phoneNumber) { this.phoneNumber = phoneNumber; }
}
}
}

View File

@ -0,0 +1,79 @@
package com.won.smarketing.recommend.infrastructure.persistence;
import com.won.smarketing.recommend.domain.model.MarketingTip;
import com.won.smarketing.recommend.domain.model.StoreData;
import com.won.smarketing.recommend.domain.model.TipId;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import jakarta.persistence.*;
import java.time.LocalDateTime;
/**
* 마케팅 JPA 엔티티 (날씨 정보 제거)
*/
@Entity
@Table(name = "marketing_tips")
@EntityListeners(AuditingEntityListener.class)
@Getter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MarketingTipEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "store_id", nullable = false)
private Long storeId;
@Column(name = "tip_content", nullable = false, length = 2000)
private String tipContent;
// 매장 정보만 저장
@Column(name = "store_name", length = 200)
private String storeName;
@Column(name = "business_type", length = 100)
private String businessType;
@Column(name = "store_location", length = 500)
private String storeLocation;
@CreatedDate
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
public static MarketingTipEntity fromDomain(MarketingTip marketingTip) {
return MarketingTipEntity.builder()
.id(marketingTip.getId() != null ? marketingTip.getId().getValue() : null)
.storeId(marketingTip.getStoreId())
.tipContent(marketingTip.getTipContent())
.storeName(marketingTip.getStoreData().getStoreName())
.businessType(marketingTip.getStoreData().getBusinessType())
.storeLocation(marketingTip.getStoreData().getLocation())
.createdAt(marketingTip.getCreatedAt())
.build();
}
public MarketingTip toDomain() {
StoreData storeData = StoreData.builder()
.storeName(this.storeName)
.businessType(this.businessType)
.location(this.storeLocation)
.build();
return MarketingTip.builder()
.id(this.id != null ? TipId.of(this.id) : null)
.storeId(this.storeId)
.tipContent(this.tipContent)
.storeData(storeData)
.createdAt(this.createdAt)
.build();
}
}

View File

@ -0,0 +1,18 @@
package com.won.smarketing.recommend.infrastructure.persistence;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
/**
* 마케팅 JPA 레포지토리
*/
@Repository
public interface MarketingTipJpaRepository extends JpaRepository<MarketingTipEntity, Long> {
@Query("SELECT m FROM MarketingTipEntity m WHERE m.storeId = :storeId ORDER BY m.createdAt DESC")
Page<MarketingTipEntity> findByStoreIdOrderByCreatedAtDesc(@Param("storeId") Long storeId, Pageable pageable);
}

View File

@ -0,0 +1,39 @@
package com.won.smarketing.recommend.infrastructure.persistence;
import com.won.smarketing.recommend.domain.model.MarketingTip;
import com.won.smarketing.recommend.domain.repository.MarketingTipRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Repository;
import java.util.Optional;
/**
* 마케팅 레포지토리 구현체
*/
@Repository
@RequiredArgsConstructor
public class MarketingTipRepositoryImpl implements MarketingTipRepository {
private final MarketingTipJpaRepository jpaRepository;
@Override
public MarketingTip save(MarketingTip marketingTip) {
MarketingTipEntity entity = MarketingTipEntity.fromDomain(marketingTip);
MarketingTipEntity savedEntity = jpaRepository.save(entity);
return savedEntity.toDomain();
}
@Override
public Optional<MarketingTip> findById(Long tipId) {
return jpaRepository.findById(tipId)
.map(MarketingTipEntity::toDomain);
}
@Override
public Page<MarketingTip> findByStoreIdOrderByCreatedAtDesc(Long storeId, Pageable pageable) {
return jpaRepository.findByStoreIdOrderByCreatedAtDesc(storeId, pageable)
.map(MarketingTipEntity::toDomain);
}
}

View File

@ -0,0 +1,34 @@
//package com.won.smarketing.recommend.presentation.controller;
//
//import org.springframework.web.bind.annotation.GetMapping;
//import org.springframework.web.bind.annotation.RestController;
//
//import java.time.LocalDateTime;
//import java.util.Map;
//
///**
// * 헬스체크 컨트롤러
// */
//@RestController
//public class HealthController {
//
// @GetMapping("/health")
// public Map<String, Object> health() {
// return Map.of(
// "status", "UP",
// "service", "ai-recommend-service",
// "timestamp", LocalDateTime.now(),
// "message", "AI 추천 서비스가 정상 동작 중입니다.",
// "features", Map.of(
// "store_integration", "매장 서비스 연동",
// "python_ai_integration", "Python AI 서비스 연동",
// "fallback_support", "Fallback 팁 생성 지원"
// )
// );
// }
//}
// }
//
// } catch (Exception e) {
// log.error("매장 정보 조회 실패, Mock 데이터 반환: storeId={}", storeId, e);
// return createMockStoreData(storeId);

View File

@ -0,0 +1,77 @@
package com.won.smarketing.recommend.presentation.controller;
import com.won.smarketing.common.dto.ApiResponse;
import com.won.smarketing.recommend.application.usecase.MarketingTipUseCase;
import com.won.smarketing.recommend.presentation.dto.MarketingTipRequest;
import com.won.smarketing.recommend.presentation.dto.MarketingTipResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
/**
* AI 마케팅 추천 컨트롤러
*/
@Tag(name = "AI 추천", description = "AI 기반 마케팅 팁 추천 API")
@Slf4j
@RestController
@RequestMapping("/api/recommendations")
@RequiredArgsConstructor
public class RecommendationController {
private final MarketingTipUseCase marketingTipUseCase;
@Operation(
summary = "AI 마케팅 팁 생성",
description = "매장 정보를 기반으로 Python AI 서비스에서 마케팅 팁을 생성합니다."
)
@PostMapping("/marketing-tips")
public ResponseEntity<ApiResponse<MarketingTipResponse>> generateMarketingTips(
@Parameter(description = "마케팅 팁 생성 요청") @Valid @RequestBody MarketingTipRequest request) {
log.info("AI 마케팅 팁 생성 요청: storeId={}", request.getStoreId());
MarketingTipResponse response = marketingTipUseCase.generateMarketingTips(request);
log.info("AI 마케팅 팁 생성 완료: tipId={}", response.getTipId());
return ResponseEntity.ok(ApiResponse.success(response));
}
@Operation(
summary = "마케팅 팁 이력 조회",
description = "특정 매장의 마케팅 팁 생성 이력을 조회합니다."
)
@GetMapping("/marketing-tips")
public ResponseEntity<ApiResponse<Page<MarketingTipResponse>>> getMarketingTipHistory(
@Parameter(description = "매장 ID") @RequestParam Long storeId,
Pageable pageable) {
log.info("마케팅 팁 이력 조회: storeId={}, page={}", storeId, pageable.getPageNumber());
Page<MarketingTipResponse> response = marketingTipUseCase.getMarketingTipHistory(storeId, pageable);
return ResponseEntity.ok(ApiResponse.success(response));
}
@Operation(
summary = "마케팅 팁 상세 조회",
description = "특정 마케팅 팁의 상세 정보를 조회합니다."
)
@GetMapping("/marketing-tips/{tipId}")
public ResponseEntity<ApiResponse<MarketingTipResponse>> getMarketingTip(
@Parameter(description = "팁 ID") @PathVariable Long tipId) {
log.info("마케팅 팁 상세 조회: tipId={}", tipId);
MarketingTipResponse response = marketingTipUseCase.getMarketingTip(tipId);
return ResponseEntity.ok(ApiResponse.success(response));
}
}

View File

@ -1,24 +1,26 @@
package com.won.smarketing.recommend.presentation.dto; package com.won.smarketing.recommend.presentation.dto;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import lombok.AllArgsConstructor; import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data; import lombok.Data;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
/** import jakarta.validation.constraints.NotNull;
* AI 마케팅 생성 요청 DTO import jakarta.validation.constraints.Positive;
* 매장 정보를 기반으로 개인화된 마케팅 팁을 요청할 사용됩니다.
*/ @Schema(description = "마케팅 팁 생성 요청")
@Data @Data
@Builder
@NoArgsConstructor @NoArgsConstructor
@AllArgsConstructor @AllArgsConstructor
@Schema(description = "AI 마케팅 팁 생성 요청")
public class MarketingTipRequest { public class MarketingTipRequest {
@Schema(description = "매장 ID", example = "1", required = true)
@NotNull(message = "매장 ID는 필수입니다") @NotNull(message = "매장 ID는 필수입니다")
@Positive(message = "매장 ID는 양수여야 합니다") @Positive(message = "매장 ID는 양수여야 합니다")
@Schema(description = "매장 ID", example = "1", required = true)
private Long storeId; private Long storeId;
@Schema(description = "추가 요청사항", example = "여름철 음료 프로모션에 집중해주세요")
private String additionalRequirement;
} }

View File

@ -0,0 +1,50 @@
package com.won.smarketing.recommend.presentation.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Schema(description = "마케팅 팁 응답")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MarketingTipResponse {
@Schema(description = "팁 ID", example = "1")
private Long tipId;
@Schema(description = "매장 ID", example = "1")
private Long storeId;
@Schema(description = "매장명", example = "카페 봄날")
private String storeName;
@Schema(description = "AI 생성 마케팅 팁 내용")
private String tipContent;
@Schema(description = "매장 정보")
private StoreInfo storeInfo;
@Schema(description = "생성 일시")
private LocalDateTime createdAt;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class StoreInfo {
@Schema(description = "매장명", example = "카페 봄날")
private String storeName;
@Schema(description = "업종", example = "카페")
private String businessType;
@Schema(description = "위치", example = "서울시 강남구")
private String location;
}
}

View File

@ -7,7 +7,7 @@ spring:
application: application:
name: ai-recommend-service name: ai-recommend-service
datasource: datasource:
url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:recommenddb} url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:AiRecommendationDB}
username: ${POSTGRES_USER:postgres} username: ${POSTGRES_USER:postgres}
password: ${POSTGRES_PASSWORD:postgres} password: ${POSTGRES_PASSWORD:postgres}
jpa: jpa:
@ -18,18 +18,29 @@ spring:
hibernate: hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect dialect: org.hibernate.dialect.PostgreSQLDialect
format_sql: true format_sql: true
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
external: external:
claude-ai:
api-key: ${CLAUDE_AI_API_KEY:your-claude-api-key}
base-url: ${CLAUDE_AI_BASE_URL:https://api.anthropic.com}
model: ${CLAUDE_AI_MODEL:claude-3-sonnet-20240229}
max-tokens: ${CLAUDE_AI_MAX_TOKENS:2000}
weather-api:
api-key: ${WEATHER_API_KEY:your-weather-api-key}
base-url: ${WEATHER_API_BASE_URL:https://api.openweathermap.org/data/2.5}
store-service: store-service:
base-url: ${STORE_SERVICE_URL:http://localhost:8082} base-url: ${STORE_SERVICE_URL:http://localhost:8082}
timeout: ${STORE_SERVICE_TIMEOUT:5000}
python-ai-service:
base-url: ${PYTHON_AI_SERVICE_URL:http://localhost:8090}
api-key: ${PYTHON_AI_API_KEY:dummy-key}
timeout: ${PYTHON_AI_TIMEOUT:30000}
management:
endpoints:
web:
exposure:
include: health,info,metrics
endpoint:
health:
show-details: always
springdoc: springdoc:
swagger-ui: swagger-ui:
@ -42,3 +53,7 @@ logging:
level: level:
com.won.smarketing.recommend: ${LOG_LEVEL:DEBUG} com.won.smarketing.recommend: ${LOG_LEVEL:DEBUG}
jwt:
secret: ${JWT_SECRET:mySecretKeyForJWTTokenGenerationAndValidation123456789}
access-token-validity: ${JWT_ACCESS_VALIDITY:3600000}
refresh-token-validity: ${JWT_REFRESH_VALIDITY:604800000}

View File

@ -1,7 +1,16 @@
plugins { plugins {
id 'org.springframework.boot' version '3.4.0' apply false
id 'io.spring.dependency-management' version '1.1.4' apply false
id 'java' id 'java'
id 'org.springframework.boot' version '3.2.0'
id 'io.spring.dependency-management' version '1.1.4'
}
allprojects {
group = 'com.won.smarketing'
version = '1.0.0'
repositories {
mavenCentral()
}
} }
subprojects { subprojects {
@ -9,45 +18,34 @@ subprojects {
apply plugin: 'org.springframework.boot' apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management' apply plugin: 'io.spring.dependency-management'
group = 'com.won.smarketing' configurations {
version = '0.0.1-SNAPSHOT' compileOnly {
sourceCompatibility = '21' extendsFrom annotationProcessor
}
repositories {
mavenCentral()
} }
dependencies { dependencies {
// Spring Boot Starters
implementation 'org.springframework.boot:spring-boot-starter'
implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-webflux'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0'
// Database implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
runtimeOnly 'org.postgresql:postgresql' implementation 'io.jsonwebtoken:jjwt-impl:0.12.3'
implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3'
// Swagger
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.4.0'
// JWT
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
// Lombok
compileOnly 'org.projectlombok:lombok' compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok'
// Test // PostgreSQL ()
runtimeOnly 'org.postgresql:postgresql:42.7.1'
testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test' testImplementation 'org.springframework.security:spring-security-test'
} }
test { tasks.named('test') {
useJUnitPlatform() useJUnitPlatform()
} }
} }

View File

@ -0,0 +1,23 @@
bootJar {
enabled = false
}
jar {
enabled = true
archiveClassifier = ''
}
// (API )
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0'
implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'
implementation 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
}

View File

@ -4,6 +4,7 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisStandaloneConfiguration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer;
@ -21,6 +22,12 @@ public class RedisConfig {
@Value("${spring.data.redis.port}") @Value("${spring.data.redis.port}")
private int redisPort; private int redisPort;
@Value("${spring.data.redis.password:}")
private String redisPassword;
@Value("${spring.data.redis.ssl:true}")
private boolean useSsl;
/** /**
* Redis 연결 팩토리 설정 * Redis 연결 팩토리 설정
* *
@ -28,7 +35,22 @@ public class RedisConfig {
*/ */
@Bean @Bean
public RedisConnectionFactory redisConnectionFactory() { public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory(redisHost, redisPort); RedisStandaloneConfiguration config = new RedisStandaloneConfiguration();
config.setHostName(redisHost);
config.setPort(redisPort);
// Azure Redis는 패스워드 인증 필수
if (redisPassword != null && !redisPassword.isEmpty()) {
config.setPassword(redisPassword);
}
LettuceConnectionFactory factory = new LettuceConnectionFactory(config);
// Azure Redis는 SSL 사용 (6380 포트)
factory.setUseSsl(useSsl);
factory.setValidateConnection(true);
return factory;
} }
/** /**

View File

@ -20,7 +20,7 @@ import java.util.Arrays;
/** /**
* Spring Security 설정 클래스 * Spring Security 설정 클래스
* 인증, 인가, CORS 보안 관련 설정 * JWT 기반 인증 CORS 설정
*/ */
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
@ -30,17 +30,7 @@ public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter; private final JwtAuthenticationFilter jwtAuthenticationFilter;
/** /**
* 패스워드 인코더 Bean 설정 * Spring Security 필터 체인 설정
*
* @return BCrypt 패스워드 인코더
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* Security Filter Chain 설정
* *
* @param http HttpSecurity 객체 * @param http HttpSecurity 객체
* @return SecurityFilterChain * @return SecurityFilterChain
@ -51,19 +41,11 @@ public class SecurityConfig {
http http
.csrf(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource())) .cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(session -> .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers( .requestMatchers("/api/auth/**", "/api/member/register", "/api/member/check-duplicate/**",
"/api/member/register", "/api/member/validate-password", "/swagger-ui/**", "/v3/api-docs/**",
"/api/member/check-duplicate", "/swagger-resources/**", "/webjars/**").permitAll()
"/api/member/validate-password",
"/api/auth/login",
"/swagger-ui/**",
"/swagger-ui.html",
"/api-docs/**",
"/actuator/**"
).permitAll()
.anyRequest().authenticated() .anyRequest().authenticated()
) )
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
@ -71,20 +53,28 @@ public class SecurityConfig {
return http.build(); return http.build();
} }
/**
* 패스워드 인코더 등록
*
* @return BCryptPasswordEncoder
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/** /**
* CORS 설정 * CORS 설정
* *
* @return CORS 설정 소스 * @return CorsConfigurationSource
*/ */
@Bean @Bean
public CorsConfigurationSource corsConfigurationSource() { public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration(); CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOriginPatterns(Arrays.asList("*")); configuration.setAllowedOriginPatterns(Arrays.asList("*"));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList("authorization", "content-type", "x-auth-token")); configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setExposedHeaders(Arrays.asList("x-auth-token"));
configuration.setAllowCredentials(true); configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration); source.registerCorsConfiguration("/**", configuration);

View File

@ -0,0 +1,43 @@
package com.won.smarketing.common.config;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* Swagger OpenAPI 설정 클래스
* API 문서화 JWT 인증 설정
*/
@Configuration
public class SwaggerConfig {
/**
* OpenAPI 설정
*
* @return OpenAPI 객체
*/
@Bean
public OpenAPI openAPI() {
String jwtSchemeName = "jwtAuth";
SecurityRequirement securityRequirement = new SecurityRequirement().addList(jwtSchemeName);
Components components = new Components()
.addSecuritySchemes(jwtSchemeName, new SecurityScheme()
.name(jwtSchemeName)
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT"));
return new OpenAPI()
.info(new Info()
.title("스마케팅 API")
.description("소상공인을 위한 AI 마케팅 서비스 API")
.version("1.0.0"))
.addSecurityItem(securityRequirement)
.components(components);
}
}

View File

@ -0,0 +1,68 @@
package com.won.smarketing.common.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 페이징 응답 DTO
* 페이징된 데이터 응답에 사용되는 공통 형식
*
* @param <T> 응답 데이터 타입
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Schema(description = "페이징 응답")
public class PageResponse<T> {
@Schema(description = "페이지 컨텐츠", example = "[...]")
private List<T> content;
@Schema(description = "페이지 번호 (0부터 시작)", example = "0")
private int pageNumber;
@Schema(description = "페이지 크기", example = "20")
private int pageSize;
@Schema(description = "전체 요소 수", example = "100")
private long totalElements;
@Schema(description = "전체 페이지 수", example = "5")
private int totalPages;
@Schema(description = "첫 번째 페이지 여부", example = "true")
private boolean first;
@Schema(description = "마지막 페이지 여부", example = "false")
private boolean last;
/**
* 성공적인 페이징 응답 생성
*
* @param content 페이지 컨텐츠
* @param pageNumber 페이지 번호
* @param pageSize 페이지 크기
* @param totalElements 전체 요소
* @param <T> 데이터 타입
* @return 페이징 응답
*/
public static <T> PageResponse<T> of(List<T> content, int pageNumber, int pageSize, long totalElements) {
int totalPages = (int) Math.ceil((double) totalElements / pageSize);
return PageResponse.<T>builder()
.content(content)
.pageNumber(pageNumber)
.pageSize(pageSize)
.totalElements(totalElements)
.totalPages(totalPages)
.first(pageNumber == 0)
.last(pageNumber >= totalPages - 1)
.build();
}
}

View File

@ -0,0 +1,79 @@
package com.won.smarketing.common.exception;
import com.won.smarketing.common.dto.ApiResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.HashMap;
import java.util.Map;
/**
* 전역 예외 처리기
* 애플리케이션 전반의 예외를 통일된 형식으로 처리
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 비즈니스 예외 처리
*
* @param ex 비즈니스 예외
* @return 오류 응답
*/
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiResponse<Void>> handleBusinessException(BusinessException ex) {
log.warn("Business exception occurred: {}", ex.getMessage());
return ResponseEntity
.status(ex.getErrorCode().getHttpStatus())
.body(ApiResponse.error(
ex.getErrorCode().getHttpStatus().value(),
ex.getMessage()
));
}
/**
* 입력값 검증 예외 처리
*
* @param ex 입력값 검증 예외
* @return 오류 응답
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Map<String, String>>> handleValidationException(
MethodArgumentNotValidException ex) {
log.warn("Validation exception occurred: {}", ex.getMessage());
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getAllErrors().forEach(error -> {
String fieldName = ((FieldError) error).getField();
String errorMessage = error.getDefaultMessage();
errors.put(fieldName, errorMessage);
});
return ResponseEntity.badRequest()
.body(ApiResponse.<Map<String, String>>builder()
.status(400)
.message("입력값 검증에 실패했습니다.")
.data(errors)
.build());
}
/**
* 일반적인 예외 처리
*
* @param ex 예외
* @return 오류 응답
*/
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleException(Exception ex) {
log.error("Unexpected exception occurred", ex);
return ResponseEntity.internalServerError()
.body(ApiResponse.error(500, "서버 내부 오류가 발생했습니다."));
}
}

View File

@ -7,8 +7,8 @@ import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.filter.OncePerRequestFilter;
@ -18,7 +18,7 @@ import java.util.Collections;
/** /**
* JWT 인증 필터 * JWT 인증 필터
* 요청 헤더에서 JWT 토큰을 추출하여 인증 처리 * HTTP 요청에서 JWT 토큰을 추출하고 인증 처리
*/ */
@Slf4j @Slf4j
@Component @Component
@ -30,44 +30,53 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
private static final String BEARER_PREFIX = "Bearer "; private static final String BEARER_PREFIX = "Bearer ";
/** /**
* JWT 토큰 인증 처리 * JWT 토큰 기반 인증 필터링
* *
* @param request HTTP 요청 * @param request HTTP 요청
* @param response HTTP 응답 * @param response HTTP 응답
* @param filterChain 필터 체인 * @param filterChain 필터 체인
* @throws ServletException 서블릿 예외 * @throws ServletException 서블릿 예외
* @throws IOException I/O 예외 * @throws IOException IO 예외
*/ */
@Override @Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException { FilterChain filterChain) throws ServletException, IOException {
// 요청 헤더에서 JWT 토큰 추출 try {
String token = resolveToken(request); String jwt = getJwtFromRequest(request);
if (StringUtils.hasText(jwt) && jwtTokenProvider.validateToken(jwt)) {
String userId = jwtTokenProvider.getUserIdFromToken(jwt);
// 사용자 인증 정보 설정
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userId, null, Collections.emptyList());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
// 토큰이 있고 유효한 경우 인증 정보 설정
if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) {
String userId = jwtTokenProvider.getUserIdFromToken(token);
Authentication authentication = new UsernamePasswordAuthenticationToken(
userId, null, Collections.emptyList());
SecurityContextHolder.getContext().setAuthentication(authentication); SecurityContextHolder.getContext().setAuthentication(authentication);
log.debug("Security context에 '{}' 인증 정보를 저장했습니다.", userId); log.debug("User '{}' authenticated successfully", userId);
}
} catch (Exception ex) {
log.error("Could not set user authentication in security context", ex);
} }
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
} }
/** /**
* 요청 헤더에서 JWT 토큰 추출 * HTTP 요청에서 JWT 토큰 추출
* *
* @param request HTTP 요청 * @param request HTTP 요청
* @return JWT 토큰 (Bearer 접두사 제거) * @return JWT 토큰 (Bearer 접두사 제거)
*/ */
private String resolveToken(HttpServletRequest request) { private String getJwtFromRequest(HttpServletRequest request) {
String bearerToken = request.getHeader(AUTHORIZATION_HEADER); String bearerToken = request.getHeader(AUTHORIZATION_HEADER);
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) { if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) {
return bearerToken.substring(BEARER_PREFIX.length()); return bearerToken.substring(BEARER_PREFIX.length());
} }
return null; return null;
} }
} }

View File

@ -0,0 +1,126 @@
package com.won.smarketing.common.security;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.util.Date;
/**
* JWT 토큰 생성 검증을 담당하는 클래스
* 액세스 토큰과 리프레시 토큰의 생성, 검증, 파싱 기능 제공
*/
@Slf4j
@Component
public class JwtTokenProvider {
private final SecretKey secretKey;
/**
* -- GETTER --
* 액세스 토큰 유효시간 반환
*
* @return 액세스 토큰 유효시간 (밀리초)
*/
@Getter
private final long accessTokenValidityTime;
private final long refreshTokenValidityTime;
/**
* JWT 토큰 프로바이더 생성자
*
* @param secret JWT 서명에 사용할 비밀키
* @param accessTokenValidityTime 액세스 토큰 유효시간 (밀리초)
* @param refreshTokenValidityTime 리프레시 토큰 유효시간 (밀리초)
*/
public JwtTokenProvider(@Value("${jwt.secret}") String secret,
@Value("${jwt.access-token-validity}") long accessTokenValidityTime,
@Value("${jwt.refresh-token-validity}") long refreshTokenValidityTime) {
this.secretKey = Keys.hmacShaKeyFor(secret.getBytes());
this.accessTokenValidityTime = accessTokenValidityTime;
this.refreshTokenValidityTime = refreshTokenValidityTime;
}
/**
* 액세스 토큰 생성
*
* @param userId 사용자 ID
* @return 생성된 액세스 토큰
*/
public String generateAccessToken(String userId) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + accessTokenValidityTime);
return Jwts.builder()
.subject(userId)
.issuedAt(now)
.expiration(expiryDate)
.signWith(secretKey)
.compact();
}
/**
* 리프레시 토큰 생성
*
* @param userId 사용자 ID
* @return 생성된 리프레시 토큰
*/
public String generateRefreshToken(String userId) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + refreshTokenValidityTime);
return Jwts.builder()
.subject(userId)
.issuedAt(now)
.expiration(expiryDate)
.signWith(secretKey)
.compact();
}
/**
* 토큰에서 사용자 ID 추출
*
* @param token JWT 토큰
* @return 사용자 ID
*/
public String getUserIdFromToken(String token) {
Claims claims = Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
return claims.getSubject();
}
/**
* 토큰 유효성 검증
*
* @param token 검증할 토큰
* @return 유효성 여부
*/
public boolean validateToken(String token) {
try {
Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token);
return true;
} catch (SecurityException ex) {
log.error("Invalid JWT signature: {}", ex.getMessage());
} catch (MalformedJwtException ex) {
log.error("Invalid JWT token: {}", ex.getMessage());
} catch (ExpiredJwtException ex) {
log.error("Expired JWT token: {}", ex.getMessage());
} catch (UnsupportedJwtException ex) {
log.error("Unsupported JWT token: {}", ex.getMessage());
} catch (IllegalArgumentException ex) {
log.error("JWT claims string is empty: {}", ex.getMessage());
}
return false;
}
}

View File

Some files were not shown because too many files have changed in this diff Show More