mirror of
https://github.com/won-ktds/smarketing-backend.git
synced 2025-12-06 07:06:24 +00:00
Merge pull request #2 from won-ktds/feature/generate-poster
Feature/generate poster
This commit is contained in:
commit
3aaa27e0fc
109
.idea/workspace.xml
generated
Normal file
109
.idea/workspace.xml
generated
Normal 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">{
|
||||||
|
"customColor": "",
|
||||||
|
"associatedIndex": 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>
|
||||||
@ -1,8 +0,0 @@
|
|||||||
tasks.getByName('bootJar') {
|
|
||||||
enabled = false
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.getByName('jar') {
|
|
||||||
enabled = true
|
|
||||||
archiveClassifier = ''
|
|
||||||
}
|
|
||||||
@ -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"
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
@ -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 "기타";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 "매우 춥다";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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 마케팅 팁이 성공적으로 생성되었습니다."));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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")));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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(), "서버 내부 오류가 발생했습니다."));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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"
|
|
||||||
}
|
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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}
|
|
||||||
@ -1,7 +0,0 @@
|
|||||||
dependencies {
|
|
||||||
implementation project(':common')
|
|
||||||
}
|
|
||||||
|
|
||||||
bootJar {
|
|
||||||
archiveFileName = "member-service.jar"
|
|
||||||
}
|
|
||||||
@ -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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
23
smarketing-ai/.gitignore
vendored
Normal 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
33
smarketing-ai/Dockerfile
Normal 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
301
smarketing-ai/app.py
Normal 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)
|
||||||
1
smarketing-ai/config/__init__.py
Normal file
1
smarketing-ai/config/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Package initialization file
|
||||||
30
smarketing-ai/config/config.py
Normal file
30
smarketing-ai/config/config.py
Normal 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
|
||||||
1
smarketing-ai/models/__init__.py
Normal file
1
smarketing-ai/models/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Package initialization file
|
||||||
67
smarketing-ai/models/request_models.py
Normal file
67
smarketing-ai/models/request_models.py
Normal 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
|
||||||
8
smarketing-ai/requirements.txt
Normal file
8
smarketing-ai/requirements.txt
Normal 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
|
||||||
1
smarketing-ai/services/__init__.py
Normal file
1
smarketing-ai/services/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Package initialization file
|
||||||
193
smarketing-ai/services/poster_service.py
Normal file
193
smarketing-ai/services/poster_service.py
Normal 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
|
||||||
382
smarketing-ai/services/poster_service_v2.py
Normal file
382
smarketing-ai/services/poster_service_v2.py
Normal 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
|
||||||
204
smarketing-ai/services/poster_service_v3.py
Normal file
204
smarketing-ai/services/poster_service_v3.py
Normal 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
|
||||||
646
smarketing-ai/services/sns_content_service.py
Normal file
646
smarketing-ai/services/sns_content_service.py
Normal 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
|
||||||
1
smarketing-ai/utils/__init__.py
Normal file
1
smarketing-ai/utils/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# Package initialization file
|
||||||
228
smarketing-ai/utils/ai_client.py
Normal file
228
smarketing-ai/utils/ai_client.py
Normal 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 "안녕하세요! 우리 가게를 찾아주셔서 감사합니다."
|
||||||
166
smarketing-ai/utils/image_processor.py
Normal file
166
smarketing-ai/utils/image_processor.py
Normal 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
|
||||||
0
.gitignore → smarketing-java/.gitignore
vendored
0
.gitignore → smarketing-java/.gitignore
vendored
4
smarketing-java/ai-recommend/build.gradle
Normal file
4
smarketing-java/ai-recommend/build.gradle
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
dependencies {
|
||||||
|
implementation project(':common')
|
||||||
|
runtimeOnly 'com.mysql:mysql-connector-j'
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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 캐시 사용
|
||||||
|
}
|
||||||
@ -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 {
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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:
|
||||||
@ -41,4 +52,8 @@ springdoc:
|
|||||||
logging:
|
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}
|
||||||
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
23
smarketing-java/common/build.gradle
Normal file
23
smarketing-java/common/build.gradle
Normal 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'
|
||||||
|
}
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -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
|
||||||
@ -49,43 +39,43 @@ public class SecurityConfig {
|
|||||||
@Bean
|
@Bean
|
||||||
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||||
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("/api/auth/**", "/api/member/register", "/api/member/check-duplicate/**",
|
||||||
.requestMatchers(
|
"/api/member/validate-password", "/swagger-ui/**", "/v3/api-docs/**",
|
||||||
"/api/member/register",
|
"/swagger-resources/**", "/webjars/**").permitAll()
|
||||||
"/api/member/check-duplicate",
|
.anyRequest().authenticated()
|
||||||
"/api/member/validate-password",
|
)
|
||||||
"/api/auth/login",
|
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||||
"/swagger-ui/**",
|
|
||||||
"/swagger-ui.html",
|
|
||||||
"/api-docs/**",
|
|
||||||
"/actuator/**"
|
|
||||||
).permitAll()
|
|
||||||
.anyRequest().authenticated()
|
|
||||||
)
|
|
||||||
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
|
||||||
|
|
||||||
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);
|
||||||
return source;
|
return source;
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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, "서버 내부 오류가 발생했습니다."));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)) {
|
||||||
if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) {
|
String userId = jwtTokenProvider.getUserIdFromToken(jwt);
|
||||||
String userId = jwtTokenProvider.getUserIdFromToken(token);
|
|
||||||
Authentication authentication = new UsernamePasswordAuthenticationToken(
|
// 사용자 인증 정보 설정
|
||||||
userId, null, Collections.emptyList());
|
UsernamePasswordAuthenticationToken authentication =
|
||||||
SecurityContextHolder.getContext().setAuthentication(authentication);
|
new UsernamePasswordAuthenticationToken(userId, null, Collections.emptyList());
|
||||||
log.debug("Security context에 '{}' 인증 정보를 저장했습니다.", userId);
|
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||||
|
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
0
gradlew → smarketing-java/gradlew
vendored
0
gradlew → smarketing-java/gradlew
vendored
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user