From 44d7312a8502f6f49d6d7d978507acd61104ad1c Mon Sep 17 00:00:00 2001 From: OhSeongRak Date: Tue, 17 Jun 2025 10:05:16 +0900 Subject: [PATCH 01/31] release --- .idea/workspace.xml | 109 + smarketing-ai/.gitignore | 23 + smarketing-ai/api/marketing_tip_api.py | 131 ++ smarketing-ai/app.py | 307 +++ smarketing-ai/config/__init__.py | 1 + smarketing-ai/config/config.py | 39 + smarketing-ai/deployment/Dockerfile | 15 + smarketing-ai/deployment/Jenkinsfile | 153 ++ smarketing-ai/deployment/Jenkinsfile_ArgoCD | 170 ++ smarketing-ai/deployment/deploy.yaml.template | 113 + smarketing-ai/deployment/deploy_env_vars | 27 + .../deployment/manifest/configmap.yaml | 11 + .../deployment/manifest/deployment.yaml | 47 + .../deployment/manifest/ingress.yaml | 26 + smarketing-ai/deployment/manifest/secret.yaml | 9 + .../deployment/manifest/service.yaml | 16 + smarketing-ai/models/__init__.py | 1 + smarketing-ai/models/marketing_tip_models.py | 93 + smarketing-ai/models/request_models.py | 71 + smarketing-ai/requirements.txt | 9 + smarketing-ai/services/__init__.py | 1 + .../services/marketing_tip_service.py | 331 +++ smarketing-ai/services/poster_service.py | 202 ++ smarketing-ai/services/sns_content_service.py | 2005 +++++++++++++++++ smarketing-ai/test.py | 42 + smarketing-ai/utils/__init__.py | 1 + smarketing-ai/utils/ai_client.py | 237 ++ smarketing-ai/utils/blob_storage.py | 117 + smarketing-ai/utils/image_processor.py | 166 ++ smarketing-java/.gitignore | 37 + smarketing-java/ai-recommend/build.gradle | 4 + .../AIRecommendServiceApplication.java | 20 + .../service/MarketingTipService.java | 168 ++ .../usecase/MarketingTipUseCase.java | 12 + .../recommend/config/CacheConfig.java | 13 + .../recommend/config/JpaConfig.java | 12 + .../recommend/config/WebClientConfig.java | 29 + .../recommend/domain/model/MarketingTip.java | 36 + .../recommend/domain/model/MenuData.java | 21 + .../recommend/domain/model/StoreData.java | 22 + .../domain/model/StoreWithMenuData.java | 13 + .../recommend/domain/model/TipId.java | 21 + .../repository/MarketingTipRepository.java | 19 + .../domain/service/AiTipGenerator.java | 18 + .../domain/service/StoreDataProvider.java | 13 + .../external/PythonAiTipGenerator.java | 143 ++ .../external/StoreApiDataProvider.java | 311 +++ .../persistence/MarketingTipEntity.java | 81 + .../MarketingTipJpaRepository.java | 40 + .../MarketingTipRepositoryImpl.java | 88 + .../controller/HealthController.java | 34 + .../controller/RecommendationController.java | 41 + .../dto/MarketingTipResponse.java | 57 + .../src/main/resources/application.yml | 52 + smarketing-java/build.gradle | 55 + smarketing-java/common/build.gradle | 23 + .../smarketing/common/config/RedisConfig.java | 69 + .../common/config/SecurityConfig.java | 83 + .../common/config/SwaggerConfig.java | 43 + .../smarketing/common/dto/ApiResponse.java | 77 + .../smarketing/common/dto/PageResponse.java | 68 + .../common/exception/BusinessException.java | 34 + .../common/exception/ErrorCode.java | 58 + .../exception/GlobalExceptionHandler.java | 79 + .../security/JwtAuthenticationFilter.java | 82 + .../common/security/JwtTokenProvider.java | 126 ++ smarketing-java/deployment/Jenkinsfile | 217 ++ .../deployment/container/Dockerfile | 44 + .../deployment/deploy.yaml.template | 475 ++++ smarketing-java/deployment/deploy_env_vars | 23 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43705 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + smarketing-java/gradlew | 251 +++ smarketing-java/gradlew.bat | 94 + .../marketing-content/build.gradle | 4 + .../MarketingContentServiceApplication.java | 29 + .../service/ContentQueryService.java | 191 ++ .../service/PosterContentService.java | 108 + .../service/SnsContentService.java | 125 + .../usecase/ContentQueryUseCase.java | 55 + .../usecase/PosterContentUseCase.java | 26 + .../usecase/SnsContentUseCase.java | 26 + .../content/config/ContentConfig.java | 9 + .../content/config/ObjectMapperConfig.java | 26 + .../content/domain/model/Content.java | 163 ++ .../content/domain/model/ContentId.java | 51 + .../content/domain/model/ContentStatus.java | 40 + .../content/domain/model/ContentType.java | 39 + .../domain/model/CreationConditions.java | 58 + .../content/domain/model/Platform.java | 41 + .../domain/repository/ContentRepository.java | 54 + .../SpringDataContentRepository.java | 38 + .../domain/service/AiContentGenerator.java | 30 + .../domain/service/AiPosterGenerator.java | 28 + .../entity/ContentConditionsJpaEntity.java | 84 + .../infrastructure/entity/ContentEntity.java | 60 + .../entity/ContentJpaEntity.java | 70 + .../external/AiContentGenerator.java | 32 + .../external/AiPosterGenerator.java | 29 + .../external/ClaudeAiContentGenerator.java | 95 + .../external/ClaudeAiPosterGenerator.java | 86 + .../infrastructure/mapper/ContentMapper.java | 213 ++ .../repository/JpaContentRepository.java | 147 ++ .../JpaContentRepositoryInterface.java | 87 + .../controller/ContentController.java | 169 ++ .../dto/ContentDetailResponse.java | 86 + .../presentation/dto/ContentListRequest.java | 37 + .../dto/ContentRegenerateRequest.java | 33 + .../presentation/dto/ContentResponse.java | 364 +++ .../dto/ContentStatisticsResponse.java | 41 + .../dto/ContentUpdateRequest.java | 33 + .../dto/ContentUpdateResponse.java | 35 + .../dto/CreationConditionsDto.java | 45 + .../dto/OngoingContentResponse.java | 47 + .../dto/PosterContentCreateRequest.java | 79 + .../dto/PosterContentCreateResponse.java | 49 + .../dto/PosterContentSaveRequest.java | 66 + .../dto/SnsContentCreateRequest.java | 160 ++ .../dto/SnsContentCreateResponse.java | 383 ++++ .../dto/SnsContentSaveRequest.java | 79 + .../src/main/resources/application.yml | 33 + smarketing-java/member/Jenkinsfile | 81 + smarketing-java/member/build.gradle | 5 + .../member/MemberServiceApplication.java | 20 + .../smarketing/member/config/JpaConfig.java | 13 + .../member/controller/AuthController.java | 64 + .../member/controller/MemberController.java | 120 + .../member/dto/DuplicateCheckResponse.java | 54 + .../smarketing/member/dto/LoginRequest.java | 26 + .../smarketing/member/dto/LoginResponse.java | 47 + .../smarketing/member/dto/LogoutRequest.java | 25 + .../member/dto/PasswordValidationRequest.java | 22 + .../member/dto/RegisterRequest.java | 49 + .../member/dto/TokenRefreshRequest.java | 22 + .../smarketing/member/dto/TokenResponse.java | 28 + .../member/dto/ValidationResponse.java | 58 + .../won/smarketing/member/entity/Member.java | 82 + .../member/repository/MemberRepository.java | 47 + .../member/service/AuthService.java | 35 + .../member/service/AuthServiceImpl.java | 166 ++ .../member/service/MemberService.java | 50 + .../member/service/MemberServiceImpl.java | 146 ++ .../member/src/main/resources/application.yml | 33 + .../member/src/main/resources/data.sql | 18 + smarketing-java/settings.gradle | 6 + smarketing-java/store/build.gradle | 8 + .../store/StoreServiceApplication.java | 20 + .../store/config/AzureBlobStorageConfig.java | 72 + .../smarketing/store/config/JpaConfig.java | 31 + .../store/controller/ImageController.java | 155 ++ .../store/controller/MenuController.java | 98 + .../store/controller/SalesController.java | 43 + .../store/controller/StoreController.java | 75 + .../store/dto/ImageUploadRequest.java | 25 + .../store/dto/ImageUploadResponse.java | 37 + .../store/dto/MenuCreateRequest.java | 45 + .../smarketing/store/dto/MenuResponse.java | 49 + .../store/dto/MenuUpdateRequest.java | 38 + .../smarketing/store/dto/SalesResponse.java | 41 + .../store/dto/StoreCreateRequest.java | 62 + .../store/dto/StoreCreateResponse.java | 56 + .../smarketing/store/dto/StoreResponse.java | 65 + .../store/dto/StoreUpdateRequest.java | 59 + .../com/won/smarketing/store/entity/Menu.java | 90 + .../won/smarketing/store/entity/Sales.java | 62 + .../won/smarketing/store/entity/Store.java | 121 + .../store/repository/MenuRepository.java | 31 + .../store/repository/SalesRepository.java | 85 + .../store/repository/StoreRepository.java | 42 + .../store/service/BlobStorageService.java | 55 + .../store/service/BlobStorageServiceImpl.java | 332 +++ .../smarketing/store/service/MenuService.java | 57 + .../store/service/MenuServiceImpl.java | 166 ++ .../store/service/SalesService.java | 17 + .../store/service/SalesServiceImpl.java | 100 + .../store/service/StoreService.java | 45 + .../store/service/StoreServiceImpl.java | 190 ++ .../store/src/main/resources/application.yml | 48 + 178 files changed, 15106 insertions(+) create mode 100644 .idea/workspace.xml create mode 100644 smarketing-ai/.gitignore create mode 100644 smarketing-ai/api/marketing_tip_api.py create mode 100644 smarketing-ai/app.py create mode 100644 smarketing-ai/config/__init__.py create mode 100644 smarketing-ai/config/config.py create mode 100644 smarketing-ai/deployment/Dockerfile create mode 100644 smarketing-ai/deployment/Jenkinsfile create mode 100644 smarketing-ai/deployment/Jenkinsfile_ArgoCD create mode 100644 smarketing-ai/deployment/deploy.yaml.template create mode 100644 smarketing-ai/deployment/deploy_env_vars create mode 100644 smarketing-ai/deployment/manifest/configmap.yaml create mode 100644 smarketing-ai/deployment/manifest/deployment.yaml create mode 100644 smarketing-ai/deployment/manifest/ingress.yaml create mode 100644 smarketing-ai/deployment/manifest/secret.yaml create mode 100644 smarketing-ai/deployment/manifest/service.yaml create mode 100644 smarketing-ai/models/__init__.py create mode 100644 smarketing-ai/models/marketing_tip_models.py create mode 100644 smarketing-ai/models/request_models.py create mode 100644 smarketing-ai/requirements.txt create mode 100644 smarketing-ai/services/__init__.py create mode 100644 smarketing-ai/services/marketing_tip_service.py create mode 100644 smarketing-ai/services/poster_service.py create mode 100644 smarketing-ai/services/sns_content_service.py create mode 100644 smarketing-ai/test.py create mode 100644 smarketing-ai/utils/__init__.py create mode 100644 smarketing-ai/utils/ai_client.py create mode 100644 smarketing-ai/utils/blob_storage.py create mode 100644 smarketing-ai/utils/image_processor.py create mode 100644 smarketing-java/.gitignore create mode 100644 smarketing-java/ai-recommend/build.gradle create mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/AIRecommendServiceApplication.java create mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java create mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/usecase/MarketingTipUseCase.java create mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/CacheConfig.java create mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/JpaConfig.java create mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/WebClientConfig.java create mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/MarketingTip.java create mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/MenuData.java create mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/StoreData.java create mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/StoreWithMenuData.java create mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/TipId.java create mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/repository/MarketingTipRepository.java create mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/AiTipGenerator.java create mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/StoreDataProvider.java create mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/PythonAiTipGenerator.java create mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/StoreApiDataProvider.java create mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipEntity.java create mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipJpaRepository.java create mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipRepositoryImpl.java create mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/HealthController.java create mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/RecommendationController.java create mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipResponse.java create mode 100644 smarketing-java/ai-recommend/src/main/resources/application.yml create mode 100644 smarketing-java/build.gradle create mode 100644 smarketing-java/common/build.gradle create mode 100644 smarketing-java/common/src/main/java/com/won/smarketing/common/config/RedisConfig.java create mode 100644 smarketing-java/common/src/main/java/com/won/smarketing/common/config/SecurityConfig.java create mode 100644 smarketing-java/common/src/main/java/com/won/smarketing/common/config/SwaggerConfig.java create mode 100644 smarketing-java/common/src/main/java/com/won/smarketing/common/dto/ApiResponse.java create mode 100644 smarketing-java/common/src/main/java/com/won/smarketing/common/dto/PageResponse.java create mode 100644 smarketing-java/common/src/main/java/com/won/smarketing/common/exception/BusinessException.java create mode 100644 smarketing-java/common/src/main/java/com/won/smarketing/common/exception/ErrorCode.java create mode 100644 smarketing-java/common/src/main/java/com/won/smarketing/common/exception/GlobalExceptionHandler.java create mode 100644 smarketing-java/common/src/main/java/com/won/smarketing/common/security/JwtAuthenticationFilter.java create mode 100644 smarketing-java/common/src/main/java/com/won/smarketing/common/security/JwtTokenProvider.java create mode 100644 smarketing-java/deployment/Jenkinsfile create mode 100644 smarketing-java/deployment/container/Dockerfile create mode 100644 smarketing-java/deployment/deploy.yaml.template create mode 100644 smarketing-java/deployment/deploy_env_vars create mode 100644 smarketing-java/gradle/wrapper/gradle-wrapper.jar create mode 100644 smarketing-java/gradle/wrapper/gradle-wrapper.properties create mode 100644 smarketing-java/gradlew create mode 100644 smarketing-java/gradlew.bat create mode 100644 smarketing-java/marketing-content/build.gradle create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/MarketingContentServiceApplication.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/ContentQueryService.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/PosterContentService.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/SnsContentService.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/ContentQueryUseCase.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/PosterContentUseCase.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/SnsContentUseCase.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/ContentConfig.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/ObjectMapperConfig.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentStatus.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentType.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Platform.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/ContentRepository.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/SpringDataContentRepository.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/AiContentGenerator.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/AiPosterGenerator.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentConditionsJpaEntity.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentEntity.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentJpaEntity.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiContentGenerator.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiPosterGenerator.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiContentGenerator.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/mapper/ContentMapper.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepository.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepositoryInterface.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/controller/ContentController.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentDetailResponse.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentListRequest.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentRegenerateRequest.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentResponse.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentStatisticsResponse.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentUpdateRequest.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentUpdateResponse.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/CreationConditionsDto.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/OngoingContentResponse.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateRequest.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateResponse.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentSaveRequest.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentCreateRequest.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentCreateResponse.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentSaveRequest.java create mode 100644 smarketing-java/marketing-content/src/main/resources/application.yml create mode 100644 smarketing-java/member/Jenkinsfile create mode 100644 smarketing-java/member/build.gradle create mode 100644 smarketing-java/member/src/main/java/com/won/smarketing/member/MemberServiceApplication.java create mode 100644 smarketing-java/member/src/main/java/com/won/smarketing/member/config/JpaConfig.java create mode 100644 smarketing-java/member/src/main/java/com/won/smarketing/member/controller/AuthController.java create mode 100644 smarketing-java/member/src/main/java/com/won/smarketing/member/controller/MemberController.java create mode 100644 smarketing-java/member/src/main/java/com/won/smarketing/member/dto/DuplicateCheckResponse.java create mode 100644 smarketing-java/member/src/main/java/com/won/smarketing/member/dto/LoginRequest.java create mode 100644 smarketing-java/member/src/main/java/com/won/smarketing/member/dto/LoginResponse.java create mode 100644 smarketing-java/member/src/main/java/com/won/smarketing/member/dto/LogoutRequest.java create mode 100644 smarketing-java/member/src/main/java/com/won/smarketing/member/dto/PasswordValidationRequest.java create mode 100644 smarketing-java/member/src/main/java/com/won/smarketing/member/dto/RegisterRequest.java create mode 100644 smarketing-java/member/src/main/java/com/won/smarketing/member/dto/TokenRefreshRequest.java create mode 100644 smarketing-java/member/src/main/java/com/won/smarketing/member/dto/TokenResponse.java create mode 100644 smarketing-java/member/src/main/java/com/won/smarketing/member/dto/ValidationResponse.java create mode 100644 smarketing-java/member/src/main/java/com/won/smarketing/member/entity/Member.java create mode 100644 smarketing-java/member/src/main/java/com/won/smarketing/member/repository/MemberRepository.java create mode 100644 smarketing-java/member/src/main/java/com/won/smarketing/member/service/AuthService.java create mode 100644 smarketing-java/member/src/main/java/com/won/smarketing/member/service/AuthServiceImpl.java create mode 100644 smarketing-java/member/src/main/java/com/won/smarketing/member/service/MemberService.java create mode 100644 smarketing-java/member/src/main/java/com/won/smarketing/member/service/MemberServiceImpl.java create mode 100644 smarketing-java/member/src/main/resources/application.yml create mode 100644 smarketing-java/member/src/main/resources/data.sql create mode 100644 smarketing-java/settings.gradle create mode 100644 smarketing-java/store/build.gradle create mode 100644 smarketing-java/store/src/main/java/com/won/smarketing/store/StoreServiceApplication.java create mode 100644 smarketing-java/store/src/main/java/com/won/smarketing/store/config/AzureBlobStorageConfig.java create mode 100644 smarketing-java/store/src/main/java/com/won/smarketing/store/config/JpaConfig.java create mode 100644 smarketing-java/store/src/main/java/com/won/smarketing/store/controller/ImageController.java create mode 100644 smarketing-java/store/src/main/java/com/won/smarketing/store/controller/MenuController.java create mode 100644 smarketing-java/store/src/main/java/com/won/smarketing/store/controller/SalesController.java create mode 100644 smarketing-java/store/src/main/java/com/won/smarketing/store/controller/StoreController.java create mode 100644 smarketing-java/store/src/main/java/com/won/smarketing/store/dto/ImageUploadRequest.java create mode 100644 smarketing-java/store/src/main/java/com/won/smarketing/store/dto/ImageUploadResponse.java create mode 100644 smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuCreateRequest.java create mode 100644 smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuResponse.java create mode 100644 smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java create mode 100644 smarketing-java/store/src/main/java/com/won/smarketing/store/dto/SalesResponse.java create mode 100644 smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreCreateRequest.java create mode 100644 smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreCreateResponse.java create mode 100644 smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreResponse.java create mode 100644 smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreUpdateRequest.java create mode 100644 smarketing-java/store/src/main/java/com/won/smarketing/store/entity/Menu.java create mode 100644 smarketing-java/store/src/main/java/com/won/smarketing/store/entity/Sales.java create mode 100644 smarketing-java/store/src/main/java/com/won/smarketing/store/entity/Store.java create mode 100644 smarketing-java/store/src/main/java/com/won/smarketing/store/repository/MenuRepository.java create mode 100644 smarketing-java/store/src/main/java/com/won/smarketing/store/repository/SalesRepository.java create mode 100644 smarketing-java/store/src/main/java/com/won/smarketing/store/repository/StoreRepository.java create mode 100644 smarketing-java/store/src/main/java/com/won/smarketing/store/service/BlobStorageService.java create mode 100644 smarketing-java/store/src/main/java/com/won/smarketing/store/service/BlobStorageServiceImpl.java create mode 100644 smarketing-java/store/src/main/java/com/won/smarketing/store/service/MenuService.java create mode 100644 smarketing-java/store/src/main/java/com/won/smarketing/store/service/MenuServiceImpl.java create mode 100644 smarketing-java/store/src/main/java/com/won/smarketing/store/service/SalesService.java create mode 100644 smarketing-java/store/src/main/java/com/won/smarketing/store/service/SalesServiceImpl.java create mode 100644 smarketing-java/store/src/main/java/com/won/smarketing/store/service/StoreService.java create mode 100644 smarketing-java/store/src/main/java/com/won/smarketing/store/service/StoreServiceImpl.java create mode 100644 smarketing-java/store/src/main/resources/application.yml diff --git a/.idea/workspace.xml b/.idea/workspace.xml new file mode 100644 index 0000000..fc7acb6 --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + { + "customColor": "", + "associatedIndex": 4 +} + + + + + + + + + + + + + + + true + true + false + false + + + + + + + 1749618504890 + + + + \ No newline at end of file diff --git a/smarketing-ai/.gitignore b/smarketing-ai/.gitignore new file mode 100644 index 0000000..0ee64c1 --- /dev/null +++ b/smarketing-ai/.gitignore @@ -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 \ No newline at end of file diff --git a/smarketing-ai/api/marketing_tip_api.py b/smarketing-ai/api/marketing_tip_api.py new file mode 100644 index 0000000..f331932 --- /dev/null +++ b/smarketing-ai/api/marketing_tip_api.py @@ -0,0 +1,131 @@ +""" +마케팅 팁 생성 API 엔드포인트 +Java 서비스와 연동되는 API +""" +from flask import Blueprint, request, jsonify +from datetime import datetime +import logging + +from services.marketing_tip_service import MarketingTipService +from models.marketing_tip_models import MarketingTipGenerateRequest, MarketingTipResponse + +logger = logging.getLogger(__name__) + +# Blueprint 생성 +marketing_tip_bp = Blueprint('marketing_tip', __name__) + +# 서비스 인스턴스 +marketing_tip_service = MarketingTipService() + + +@marketing_tip_bp.route('/api/v1/generate-marketing-tip', methods=['POST']) +def generate_marketing_tip(): + """ + AI 마케팅 팁 생성 API + Java 서비스에서 호출하는 엔드포인트 + """ + try: + # 요청 데이터 검증 + if not request.is_json: + return jsonify({ + 'tip': '', + 'status': 'error', + 'message': 'Content-Type이 application/json이어야 합니다.', + 'generated_at': '', + 'store_name': '', + 'business_type': '', + 'ai_model': '' + }), 400 + + data = request.get_json() + if not data: + return jsonify({ + 'tip': '', + 'status': 'error', + 'message': '요청 데이터가 없습니다.', + 'generated_at': '', + 'store_name': '', + 'business_type': '', + 'ai_model': '' + }), 400 + + # 필수 필드 검증 + if 'store_name' not in data or not data['store_name']: + return jsonify({ + 'tip': '', + 'status': 'error', + 'message': '매장명(store_name)은 필수입니다.', + 'generated_at': '', + 'store_name': '', + 'business_type': '', + 'ai_model': '' + }), 400 + + if 'business_type' not in data or not data['business_type']: + return jsonify({ + 'tip': '', + 'status': 'error', + 'message': '업종(business_type)은 필수입니다.', + 'generated_at': '', + 'store_name': '', + 'business_type': '', + 'ai_model': '' + }), 400 + + logger.info(f"마케팅 팁 생성 요청: {data.get('store_name', 'Unknown')}") + + # 요청 모델 생성 + try: + request_model = MarketingTipGenerateRequest(**data) + except ValueError as e: + return jsonify({ + 'tip': '', + 'status': 'error', + 'message': f'요청 데이터 형식이 올바르지 않습니다: {str(e)}', + 'generated_at': '', + 'store_name': data.get('store_name', ''), + 'business_type': data.get('business_type', ''), + 'ai_model': '' + }), 400 + + # 매장 정보 구성 + store_data = { + 'store_name': request_model.store_name, + 'business_type': request_model.business_type, + 'location': request_model.location or '', + 'seat_count': request_model.seat_count or 0 + } + + # 마케팅 팁 생성 + result = marketing_tip_service.generate_marketing_tip( + store_data=store_data, + ) + + logger.info(f"마케팅 팁 생성 완료: {result.get('store_name', 'Unknown')}") + + return jsonify(result), 200 + + except Exception as e: + logger.error(f"마케팅 팁 생성 API 오류: {str(e)}") + + return jsonify({ + 'tip': '죄송합니다. 일시적인 오류로 마케팅 팁을 생성할 수 없습니다. 잠시 후 다시 시도해주세요.', + 'status': 'error', + 'message': f'서버 오류가 발생했습니다: {str(e)}', + 'generated_at': '', + 'store_name': data.get('store_name', '') if 'data' in locals() else '', + 'business_type': data.get('business_type', '') if 'data' in locals() else '', + 'ai_model': 'error' + }), 500 + + +@marketing_tip_bp.route('/api/v1/health', methods=['GET']) +def health_check(): + """ + 헬스체크 API + """ + return jsonify({ + 'status': 'healthy', + 'service': 'marketing-tip-api', + 'timestamp': datetime.now().isoformat() + }), 200 \ No newline at end of file diff --git a/smarketing-ai/app.py b/smarketing-ai/app.py new file mode 100644 index 0000000..d3c91da --- /dev/null +++ b/smarketing-ai/app.py @@ -0,0 +1,307 @@ +""" +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.sns_content_service import SnsContentService +from services.poster_service import PosterService +from models.request_models import ContentRequest, PosterRequest, SnsContentGetRequest, PosterContentGetRequest +from api.marketing_tip_api import marketing_tip_bp + +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() + sns_content_service = SnsContentService() + + # Blueprint 등록 + app.register_blueprint(marketing_tip_bp) + + @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'), + storeName=data.get('storeName'), + storeType=data.get('storeType'), + target=data.get('target'), + #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.generate_poster(poster_request) + + if result['success']: + return jsonify({ + 'content': result['content'], + }) + 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() + host = os.getenv('SERVER_HOST', '0.0.0.0') + port = int(os.getenv('SERVER_PORT', '5001')) + + app.run(host=host, port=port, debug=True) diff --git a/smarketing-ai/config/__init__.py b/smarketing-ai/config/__init__.py new file mode 100644 index 0000000..6ae5294 --- /dev/null +++ b/smarketing-ai/config/__init__.py @@ -0,0 +1 @@ +# Package initialization file diff --git a/smarketing-ai/config/config.py b/smarketing-ai/config/config.py new file mode 100644 index 0000000..dc6e8ac --- /dev/null +++ b/smarketing-ai/config/config.py @@ -0,0 +1,39 @@ +""" +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 * 1536) # 16MB + + # AI API 설정 + CLAUDE_API_KEY = os.environ.get('CLAUDE_API_KEY') + OPENAI_API_KEY = os.environ.get('OPENAI_API_KEY') + + # Azure Blob Storage 설정 + AZURE_STORAGE_ACCOUNT_NAME = os.environ.get('AZURE_STORAGE_ACCOUNT_NAME') or 'stdigitalgarage02' + AZURE_STORAGE_ACCOUNT_KEY = os.environ.get('AZURE_STORAGE_ACCOUNT_KEY') + AZURE_STORAGE_CONTAINER_NAME = os.environ.get('AZURE_STORAGE_CONTAINER_NAME') or 'ai-content' + + # 지원되는 파일 확장자 + 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 diff --git a/smarketing-ai/deployment/Dockerfile b/smarketing-ai/deployment/Dockerfile new file mode 100644 index 0000000..223ed21 --- /dev/null +++ b/smarketing-ai/deployment/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.11-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# 애플리케이션 코드 복사 +COPY . . + +# 포트 노출 +EXPOSE 5001 + +# 애플리케이션 실행 +CMD ["python", "app.py"] \ No newline at end of file diff --git a/smarketing-ai/deployment/Jenkinsfile b/smarketing-ai/deployment/Jenkinsfile new file mode 100644 index 0000000..1abbdb4 --- /dev/null +++ b/smarketing-ai/deployment/Jenkinsfile @@ -0,0 +1,153 @@ +def PIPELINE_ID = "${env.BUILD_NUMBER}" + +def getImageTag() { + def dateFormat = new java.text.SimpleDateFormat('yyyyMMddHHmmss') + def currentDate = new Date() + return dateFormat.format(currentDate) +} + +podTemplate( + label: "${PIPELINE_ID}", + serviceAccount: 'jenkins', + containers: [ + containerTemplate(name: 'podman', image: "mgoltzsche/podman", ttyEnabled: true, command: 'cat', privileged: true), + containerTemplate(name: 'azure-cli', image: 'hiondal/azure-kubectl:latest', command: 'cat', ttyEnabled: true), + containerTemplate(name: 'envsubst', image: "hiondal/envsubst", command: 'sleep', args: '1h') + ], + volumes: [ + emptyDirVolume(mountPath: '/run/podman', memory: false), + emptyDirVolume(mountPath: '/root/.azure', memory: false) + ] +) { + node(PIPELINE_ID) { + def props + def imageTag = getImageTag() + def manifest = "deploy.yaml" + def namespace + + stage("Get Source") { + checkout scm + props = readProperties file: "deployment/deploy_env_vars" + namespace = "${props.namespace}" + } + + stage("Setup AKS") { + container('azure-cli') { + withCredentials([azureServicePrincipal('azure-credentials')]) { + sh """ + az login --service-principal -u \$AZURE_CLIENT_ID -p \$AZURE_CLIENT_SECRET -t \$AZURE_TENANT_ID + az aks get-credentials --resource-group rg-digitalgarage-02 --name aks-digitalgarage-02 --overwrite-existing + kubectl create namespace ${namespace} --dry-run=client -o yaml | kubectl apply -f - + """ + } + } + } + + stage('Build & Push Docker Image') { + container('podman') { + sh 'podman system service -t 0 unix:///run/podman/podman.sock & sleep 2' + + withCredentials([usernamePassword( + credentialsId: 'acr-credentials', + usernameVariable: 'ACR_USERNAME', + passwordVariable: 'ACR_PASSWORD' + )]) { + sh """ + echo "==========================================" + echo "Building smarketing-ai Python Flask application" + echo "Image Tag: ${imageTag}" + echo "==========================================" + + # ACR 로그인 + echo \$ACR_PASSWORD | podman login ${props.registry} --username \$ACR_USERNAME --password-stdin + + # Docker 이미지 빌드 + podman build \ + -f deployment/container/Dockerfile \ + -t ${props.registry}/${props.image_org}/smarketing-ai:${imageTag} . + + # 이미지 푸시 + podman push ${props.registry}/${props.image_org}/smarketing-ai:${imageTag} + + echo "Successfully built and pushed: ${props.registry}/${props.image_org}/smarketing-ai:${imageTag}" + """ + } + } + } + + stage('Generate & Apply Manifest') { + container('envsubst') { + withCredentials([ + string(credentialsId: 'secret-key', variable: 'SECRET_KEY'), + string(credentialsId: 'claude-api-key', variable: 'CLAUDE_API_KEY'), + string(credentialsId: 'openai-api-key', variable: 'OPENAI_API_KEY'), + string(credentialsId: 'azure-storage-account-name', variable: 'AZURE_STORAGE_ACCOUNT_NAME'), + string(credentialsId: 'azure-storage-account-key', variable: 'AZURE_STORAGE_ACCOUNT_KEY') + ]) { + sh """ + export namespace=${namespace} + export replicas=${props.replicas} + export resources_requests_cpu=${props.resources_requests_cpu} + export resources_requests_memory=${props.resources_requests_memory} + export resources_limits_cpu=${props.resources_limits_cpu} + export resources_limits_memory=${props.resources_limits_memory} + export upload_folder=${props.upload_folder} + export max_content_length=${props.max_content_length} + export allowed_extensions=${props.allowed_extensions} + export server_host=${props.server_host} + export server_port=${props.server_port} + export azure_storage_container_name=${props.azure_storage_container_name} + + # 이미지 경로 환경변수 설정 + export smarketing_image_path=${props.registry}/${props.image_org}/smarketing-ai:${imageTag} + + # Sensitive 환경변수 설정 (Jenkins Credentials에서) + export secret_key=\$SECRET_KEY + export claude_api_key=\$CLAUDE_API_KEY + export openai_api_key=\$OPENAI_API_KEY + export azure_storage_account_name=\$AZURE_STORAGE_ACCOUNT_NAME + export azure_storage_account_key=\$AZURE_STORAGE_ACCOUNT_KEY + + # manifest 생성 + envsubst < deployment/${manifest}.template > deployment/${manifest} + echo "Generated manifest file:" + cat deployment/${manifest} + """ + } + } + + container('azure-cli') { + sh """ + kubectl apply -f deployment/${manifest} + + echo "Waiting for smarketing deployment to be ready..." + kubectl -n ${namespace} wait --for=condition=available deployment/smarketing --timeout=300s + + echo "==========================================" + echo "Getting LoadBalancer External IP..." + + # External IP 확인 (최대 5분 대기) + for i in {1..30}; do + EXTERNAL_IP=\$(kubectl -n ${namespace} get service smarketing-service -o jsonpath='{.status.loadBalancer.ingress[0].ip}') + if [ "\$EXTERNAL_IP" != "" ] && [ "\$EXTERNAL_IP" != "null" ]; then + echo "External IP assigned: \$EXTERNAL_IP" + break + fi + echo "Waiting for External IP... (attempt \$i/30)" + sleep 10 + done + + # 서비스 상태 확인 + kubectl -n ${namespace} get pods -l app=smarketing + kubectl -n ${namespace} get service smarketing-service + + echo "==========================================" + echo "Deployment Complete!" + echo "Service URL: http://\$EXTERNAL_IP:${props.server_port}" + echo "Health Check: http://\$EXTERNAL_IP:${props.server_port}/health" + echo "==========================================" + """ + } + } + } +} \ No newline at end of file diff --git a/smarketing-ai/deployment/Jenkinsfile_ArgoCD b/smarketing-ai/deployment/Jenkinsfile_ArgoCD new file mode 100644 index 0000000..1f86a02 --- /dev/null +++ b/smarketing-ai/deployment/Jenkinsfile_ArgoCD @@ -0,0 +1,170 @@ +def PIPELINE_ID = "${env.BUILD_NUMBER}" + +def getImageTag() { + def dateFormat = new java.text.SimpleDateFormat('yyyyMMddHHmmss') + def currentDate = new Date() + return dateFormat.format(currentDate) +} + +podTemplate( + label: "${PIPELINE_ID}", + serviceAccount: 'jenkins', + containers: [ + containerTemplate(name: 'podman', image: "mgoltzsche/podman", ttyEnabled: true, command: 'cat', privileged: true), + containerTemplate(name: 'git', image: 'alpine/git:latest', command: 'cat', ttyEnabled: true) + ], + volumes: [ + emptyDirVolume(mountPath: '/run/podman', memory: false) + ] +) { + node(PIPELINE_ID) { + def props + def imageTag = getImageTag() + + stage("Get Source") { + checkout scm + props = readProperties file: "deployment/deploy_env_vars" + } + + stage('Build & Push Docker Image') { + container('podman') { + sh 'podman system service -t 0 unix:///run/podman/podman.sock & sleep 2' + + withCredentials([usernamePassword( + credentialsId: 'acr-credentials', + usernameVariable: 'ACR_USERNAME', + passwordVariable: 'ACR_PASSWORD' + )]) { + sh """ + echo "==========================================" + echo "Building smarketing-ai for ArgoCD GitOps" + echo "Image Tag: ${imageTag}" + echo "==========================================" + + # ACR 로그인 + echo \$ACR_PASSWORD | podman login ${props.registry} --username \$ACR_USERNAME --password-stdin + + # Docker 이미지 빌드 + podman build \ + -f deployment/container/Dockerfile \ + -t ${props.registry}/${props.image_org}/smarketing-ai:${imageTag} . + + # 이미지 푸시 + podman push ${props.registry}/${props.image_org}/smarketing-ai:${imageTag} + + echo "Successfully built and pushed: ${props.registry}/${props.image_org}/smarketing-ai:${imageTag}" + """ + } + } + } + + stage('Update Manifest Repository') { + container('git') { + withCredentials([usernamePassword( + credentialsId: 'github-credentials-${props.teamid}', + usernameVariable: 'GIT_USERNAME', + passwordVariable: 'GIT_PASSWORD' + )]) { + sh """ + # Git 설정 + git config --global user.email "jenkins@company.com" + git config --global user.name "Jenkins CI" + + # Manifest 저장소 클론 (팀별 저장소로 수정 필요) + git clone https://\${GIT_USERNAME}:\${GIT_PASSWORD}@github.com/your-team/smarketing-ai-manifest.git + cd smarketing-ai-manifest + + echo "==========================================" + echo "Updating smarketing-ai manifest repository:" + echo "New Image: ${props.registry}/${props.image_org}/smarketing-ai:${imageTag}" + + # smarketing deployment 파일 업데이트 + if [ -f "smarketing/smarketing-deployment.yaml" ]; then + # 이미지 태그 업데이트 + sed -i "s|image: ${props.registry}/${props.image_org}/smarketing-ai:.*|image: ${props.registry}/${props.image_org}/smarketing-ai:${imageTag}|g" \ + smarketing/smarketing-deployment.yaml + + echo "Updated smarketing deployment to image tag: ${imageTag}" + cat smarketing/smarketing-deployment.yaml | grep "image:" + else + echo "Warning: smarketing-deployment.yaml not found" + echo "Creating manifest directory structure..." + + # 기본 구조 생성 + mkdir -p smarketing + + # 기본 deployment 파일 생성 + cat > smarketing/smarketing-deployment.yaml << EOF +apiVersion: apps/v1 +kind: Deployment +metadata: + name: smarketing + namespace: smarketing + labels: + app: smarketing +spec: + replicas: 1 + selector: + matchLabels: + app: smarketing + template: + metadata: + labels: + app: smarketing + spec: + imagePullSecrets: + - name: acr-secret + containers: + - name: smarketing + image: ${props.registry}/${props.image_org}/smarketing-ai:${imageTag} + imagePullPolicy: Always + ports: + - containerPort: 5001 + resources: + requests: + cpu: 256m + memory: 512Mi + limits: + cpu: 1024m + memory: 2048Mi + envFrom: + - configMapRef: + name: smarketing-config + - secretRef: + name: smarketing-secret + volumeMounts: + - name: upload-storage + mountPath: /app/uploads + - name: temp-storage + mountPath: /app/uploads/temp + volumes: + - name: upload-storage + emptyDir: {} + - name: temp-storage + emptyDir: {} +EOF + echo "Created basic smarketing-deployment.yaml" + fi + + # 변경사항 커밋 및 푸시 + git add . + git commit -m "Update smarketing-ai image tag to ${imageTag} + + Image: ${props.registry}/${props.image_org}/smarketing-ai:${imageTag} + Build: ${env.BUILD_NUMBER} + Branch: ${env.BRANCH_NAME} + Commit: ${env.GIT_COMMIT}" + + git push origin main + + echo "==========================================" + echo "ArgoCD GitOps Update Completed!" + echo "Updated Service: smarketing-ai:${imageTag}" + echo "ArgoCD will automatically detect and deploy these changes." + echo "==========================================" + """ + } + } + } + } +} \ No newline at end of file diff --git a/smarketing-ai/deployment/deploy.yaml.template b/smarketing-ai/deployment/deploy.yaml.template new file mode 100644 index 0000000..2f35b44 --- /dev/null +++ b/smarketing-ai/deployment/deploy.yaml.template @@ -0,0 +1,113 @@ +# ConfigMap +apiVersion: v1 +kind: ConfigMap +metadata: + name: smarketing-config + namespace: ${namespace} +data: + SERVER_HOST: "${server_host}" + SERVER_PORT: "${server_port}" + UPLOAD_FOLDER: "${upload_folder}" + MAX_CONTENT_LENGTH: "${max_content_length}" + ALLOWED_EXTENSIONS: "${allowed_extensions}" + AZURE_STORAGE_CONTAINER_NAME: "${azure_storage_container_name}" + +--- +# Secret +apiVersion: v1 +kind: Secret +metadata: + name: smarketing-secret + namespace: ${namespace} +type: Opaque +stringData: + SECRET_KEY: "${secret_key}" + CLAUDE_API_KEY: "${claude_api_key}" + OPENAI_API_KEY: "${openai_api_key}" + AZURE_STORAGE_ACCOUNT_NAME: "${azure_storage_account_name}" + AZURE_STORAGE_ACCOUNT_KEY: "${azure_storage_account_key}" + +--- +# Deployment +apiVersion: apps/v1 +kind: Deployment +metadata: + name: smarketing + namespace: ${namespace} + labels: + app: smarketing +spec: + replicas: ${replicas} + selector: + matchLabels: + app: smarketing + template: + metadata: + labels: + app: smarketing + spec: + imagePullSecrets: + - name: acr-secret + containers: + - name: smarketing + image: ${smarketing_image_path} + imagePullPolicy: Always + ports: + - containerPort: 5001 + resources: + requests: + cpu: ${resources_requests_cpu} + memory: ${resources_requests_memory} + limits: + cpu: ${resources_limits_cpu} + memory: ${resources_limits_memory} + envFrom: + - configMapRef: + name: smarketing-config + - secretRef: + name: smarketing-secret + volumeMounts: + - name: upload-storage + mountPath: /app/uploads + - name: temp-storage + mountPath: /app/uploads/temp + livenessProbe: + httpGet: + path: /health + port: 5001 + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health + port: 5001 + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + volumes: + - name: upload-storage + emptyDir: {} + - name: temp-storage + emptyDir: {} + +--- +# Service (LoadBalancer type for External IP) +apiVersion: v1 +kind: Service +metadata: + name: smarketing-service + namespace: ${namespace} + labels: + app: smarketing +spec: + type: LoadBalancer + ports: + - port: 5001 + targetPort: 5001 + protocol: TCP + name: http + selector: + app: smarketing \ No newline at end of file diff --git a/smarketing-ai/deployment/deploy_env_vars b/smarketing-ai/deployment/deploy_env_vars new file mode 100644 index 0000000..6f33b33 --- /dev/null +++ b/smarketing-ai/deployment/deploy_env_vars @@ -0,0 +1,27 @@ +# Team Settings +teamid=won +root_project=smarketing-ai +namespace=smarketing + +# Container Registry Settings +registry=acrdigitalgarage02.azurecr.io +image_org=won + +# Application Settings +replicas=1 + +# Resource Settings +resources_requests_cpu=256m +resources_requests_memory=512Mi +resources_limits_cpu=1024m +resources_limits_memory=2048Mi + +# Flask App Settings (non-sensitive) +upload_folder=/app/uploads +max_content_length=16777216 +allowed_extensions=png,jpg,jpeg,gif,webp +server_host=0.0.0.0 +server_port=5001 + +# Azure Storage Settings (non-sensitive) +azure_storage_container_name=ai-content \ No newline at end of file diff --git a/smarketing-ai/deployment/manifest/configmap.yaml b/smarketing-ai/deployment/manifest/configmap.yaml new file mode 100644 index 0000000..798804c --- /dev/null +++ b/smarketing-ai/deployment/manifest/configmap.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: smarketing-config + namespace: smarketing +data: + SERVER_HOST: "0.0.0.0" + SERVER_PORT: "5001" + UPLOAD_FOLDER: "/app/uploads" + MAX_CONTENT_LENGTH: "16777216" # 16MB + ALLOWED_EXTENSIONS: "png,jpg,jpeg,gif,webp" \ No newline at end of file diff --git a/smarketing-ai/deployment/manifest/deployment.yaml b/smarketing-ai/deployment/manifest/deployment.yaml new file mode 100644 index 0000000..cc53cb5 --- /dev/null +++ b/smarketing-ai/deployment/manifest/deployment.yaml @@ -0,0 +1,47 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: smarketing + namespace: smarketing + labels: + app: smarketing +spec: + replicas: 1 + selector: + matchLabels: + app: smarketing + template: + metadata: + labels: + app: smarketing + spec: + imagePullSecrets: + - name: acr-secret + containers: + - name: smarketing + image: acrdigitalgarage02.azurecr.io/smarketing-ai:latest + imagePullPolicy: Always + ports: + - containerPort: 5001 + resources: + requests: + cpu: 256m + memory: 512Mi + limits: + cpu: 1024m + memory: 2048Mi + envFrom: + - configMapRef: + name: smarketing-config + - secretRef: + name: smarketing-secret + volumeMounts: + - name: upload-storage + mountPath: /app/uploads + - name: temp-storage + mountPath: /app/uploads/temp + volumes: + - name: upload-storage + emptyDir: {} + - name: temp-storage + emptyDir: {} \ No newline at end of file diff --git a/smarketing-ai/deployment/manifest/ingress.yaml b/smarketing-ai/deployment/manifest/ingress.yaml new file mode 100644 index 0000000..5b5c4f4 --- /dev/null +++ b/smarketing-ai/deployment/manifest/ingress.yaml @@ -0,0 +1,26 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: smarketing-ingress + namespace: smarketing + annotations: + kubernetes.io/ingress.class: nginx + nginx.ingress.kubernetes.io/proxy-body-size: "16m" + nginx.ingress.kubernetes.io/proxy-read-timeout: "300" + nginx.ingress.kubernetes.io/proxy-send-timeout: "300" + nginx.ingress.kubernetes.io/cors-allow-methods: "GET, POST, PUT, DELETE, OPTIONS" + nginx.ingress.kubernetes.io/cors-allow-headers: "DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization" + nginx.ingress.kubernetes.io/cors-allow-origin: "*" + nginx.ingress.kubernetes.io/enable-cors: "true" +spec: + rules: + - host: smarketing.20.249.184.228.nip.io + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: smarketing-service + port: + number: 80 \ No newline at end of file diff --git a/smarketing-ai/deployment/manifest/secret.yaml b/smarketing-ai/deployment/manifest/secret.yaml new file mode 100644 index 0000000..cf24d0b --- /dev/null +++ b/smarketing-ai/deployment/manifest/secret.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Secret +metadata: + name: smarketing-secret + namespace: smarketing +type: Opaque + +data: + OPENAI_API_KEY: c2stcHJvai1BbjRRX3VTNnNzQkxLU014VXBYTDBPM0lteUJuUjRwNVFTUHZkRnNSeXpFWGE0M21ISnhBcUkzNGZQOEduV2ZxclBpQ29VZ2pmbFQzQmxia0ZKZklMUGVqUFFIem9ZYzU4Yzc4UFkzeUo0dkowTVlfNGMzNV82dFlQUlkzTDBIODAwWWVvMnpaTmx6V3hXNk1RMFRzSDg5T1lNWUEK \ No newline at end of file diff --git a/smarketing-ai/deployment/manifest/service.yaml b/smarketing-ai/deployment/manifest/service.yaml new file mode 100644 index 0000000..08dc1e8 --- /dev/null +++ b/smarketing-ai/deployment/manifest/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: smarketing-service + namespace: smarketing + labels: + app: smarketing +spec: + type: LoadBalancer + ports: + - port: 5001 + targetPort: 5001 + protocol: TCP + name: http + selector: + app: smarketing \ No newline at end of file diff --git a/smarketing-ai/models/__init__.py b/smarketing-ai/models/__init__.py new file mode 100644 index 0000000..6ae5294 --- /dev/null +++ b/smarketing-ai/models/__init__.py @@ -0,0 +1 @@ +# Package initialization file diff --git a/smarketing-ai/models/marketing_tip_models.py b/smarketing-ai/models/marketing_tip_models.py new file mode 100644 index 0000000..5c47e84 --- /dev/null +++ b/smarketing-ai/models/marketing_tip_models.py @@ -0,0 +1,93 @@ +""" +마케팅 팁 API 요청/응답 모델 +""" +from pydantic import BaseModel, Field +from typing import Optional, Dict, Any, List +from datetime import datetime + + +class MenuInfo(BaseModel): + """메뉴 정보 모델""" + + menu_id: int = Field(..., description="메뉴 ID") + menu_name: str = Field(..., description="메뉴명") + category: str = Field(..., description="메뉴 카테고리") + price: int = Field(..., description="가격") + description: Optional[str] = Field(None, description="메뉴 설명") + + class Config: + schema_extra = { + "example": { + "store_name": "더블샷 카페", + "business_type": "카페", + "location": "서울시 강남구 역삼동", + "seat_count": 30, + "menu_list": [ + { + "menu_id": 1, + "menu_name": "아메리카노", + "category": "음료", + "price": 4000, + "description": "깊고 진한 맛의 아메리카노" + }, + { + "menu_id": 2, + "menu_name": "카페라떼", + "category": "음료", + "price": 4500, + "description": "부드러운 우유 거품이 올라간 카페라떼" + }, + { + "menu_id": 3, + "menu_name": "치즈케이크", + "category": "디저트", + "price": 6000, + "description": "진한 치즈 맛의 수제 케이크" + } + ], + "additional_requirement": "젊은 고객층을 타겟으로 한 마케팅" + } + } + +class MarketingTipGenerateRequest(BaseModel): + """마케팅 팁 생성 요청 모델""" + + store_name: str = Field(..., description="매장명") + business_type: str = Field(..., description="업종") + location: Optional[str] = Field(None, description="위치") + seat_count: Optional[int] = Field(None, description="좌석 수") + menu_list: Optional[List[MenuInfo]] = Field(default=[], description="메뉴 목록") + + class Config: + schema_extra = { + "example": { + "store_name": "더블샷 카페", + "business_type": "카페", + "location": "서울시 강남구 역삼동", + "seat_count": 30, + } + } + +class MarketingTipResponse(BaseModel): + """마케팅 팁 응답 모델""" + + tip: str = Field(..., description="생성된 마케팅 팁") + status: str = Field(..., description="응답 상태 (success, fallback, error)") + message: str = Field(..., description="응답 메시지") + generated_at: str = Field(..., description="생성 시간") + store_name: str = Field(..., description="매장명") + business_type: str = Field(..., description="업종") + ai_model: str = Field(..., description="사용된 AI 모델") + + class Config: + schema_extra = { + "example": { + "tip": "☕ 더블샷 카페 여름 마케팅 전략\n\n💡 핵심 포인트:\n1. 여름 한정 시원한 음료 개발\n2. SNS 이벤트로 젊은 고객층 공략\n3. 더위 피할 수 있는 쾌적한 환경 어필", + "status": "success", + "message": "AI 마케팅 팁이 성공적으로 생성되었습니다.", + "generated_at": "2024-06-13T15:30:00", + "store_name": "더블샷 카페", + "business_type": "카페", + "ai_model": "claude" + } + } diff --git a/smarketing-ai/models/request_models.py b/smarketing-ai/models/request_models.py new file mode 100644 index 0000000..3f6952d --- /dev/null +++ b/smarketing-ai/models/request_models.py @@ -0,0 +1,71 @@ +""" +요청 모델 정의 +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 리스트 + target : Optional[str] = None # 타켓 + requirement: Optional[str] = None + storeName: Optional[str] = None + storeType: 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 diff --git a/smarketing-ai/requirements.txt b/smarketing-ai/requirements.txt new file mode 100644 index 0000000..24cd87b --- /dev/null +++ b/smarketing-ai/requirements.txt @@ -0,0 +1,9 @@ +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 +azure-storage-blob>=12.19.0 \ No newline at end of file diff --git a/smarketing-ai/services/__init__.py b/smarketing-ai/services/__init__.py new file mode 100644 index 0000000..6ae5294 --- /dev/null +++ b/smarketing-ai/services/__init__.py @@ -0,0 +1 @@ +# Package initialization file diff --git a/smarketing-ai/services/marketing_tip_service.py b/smarketing-ai/services/marketing_tip_service.py new file mode 100644 index 0000000..db5526a --- /dev/null +++ b/smarketing-ai/services/marketing_tip_service.py @@ -0,0 +1,331 @@ +""" +마케팅 팁 생성 서비스 +Java 서비스에서 요청받은 매장 정보를 기반으로 AI 마케팅 팁을 생성 +""" +import os +import logging +from typing import Dict, Any, Optional +import anthropic +import openai +from datetime import datetime + +# 로깅 설정 +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + + +class MarketingTipService: + """마케팅 팁 생성 서비스 클래스""" + + def __init__(self): + """서비스 초기화""" + 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 + logger.warning("Claude API 키가 설정되지 않았습니다.") + + # OpenAI 클라이언트 초기화 + if self.openai_api_key: + self.openai_client = openai.OpenAI(api_key=self.openai_api_key) + else: + self.openai_client = None + logger.warning("OpenAI API 키가 설정되지 않았습니다.") + + def generate_marketing_tip(self, store_data: Dict[str, Any], additional_requirement: Optional[str] = None) -> Dict[str, Any]: + """ + 매장 정보를 기반으로 AI 마케팅 팁 생성 + + Args: + store_data: 매장 정보 (store_name, business_type, location 등) + + Returns: + 생성된 마케팅 팁과 메타데이터 + """ + try: + logger.info(f"마케팅 팁 생성 시작: {store_data.get('store_name', 'Unknown')}") + + # 1. 프롬프트 생성 + prompt = self._create_marketing_prompt(store_data, additional_requirement) + + # 2. AI 서비스 호출 (Claude 우선, 실패 시 OpenAI) + tip_content = self._call_ai_service(prompt) + + # 3. 응답 데이터 구성 + response = { + 'tip': tip_content, + 'status': 'success', + 'message': 'AI 마케팅 팁이 성공적으로 생성되었습니다.', + 'generated_at': datetime.now().isoformat(), + 'store_name': store_data.get('store_name', ''), + 'business_type': store_data.get('business_type', ''), + 'ai_model': 'claude' if self.claude_client else 'openai' + } + + logger.info(f"마케팅 팁 생성 완료: {store_data.get('store_name', 'Unknown')}") + logger.info(f"마케팅 팁 생성 완료: {response}") + return response + + except Exception as e: + logger.error(f"마케팅 팁 생성 실패: {str(e)}") + + # 실패 시 Fallback 팁 반환 + fallback_tip = self._create_fallback_tip(store_data, additional_requirement) + + return { + 'tip': fallback_tip, + 'status': 'fallback', + 'message': 'AI 서비스 호출 실패로 기본 팁을 제공합니다.', + 'generated_at': datetime.now().isoformat(), + 'store_name': store_data.get('store_name', ''), + 'business_type': store_data.get('business_type', ''), + 'ai_model': 'fallback' + } + + def _create_marketing_prompt(self, store_data: Dict[str, Any], additional_requirement: Optional[str]) -> str: + """마케팅 팁 생성을 위한 프롬프트 생성""" + + store_name = store_data.get('store_name', '매장') + business_type = store_data.get('business_type', '소상공인') + location = store_data.get('location', '') + seat_count = store_data.get('seat_count', 0) + menu_list = store_data.get('menu_list', []) + + prompt = f""" +당신은 소상공인 마케팅 전문가입니다. +현재 유행하고 성공한 마케팅 예시를 검색하여 확인 한 후, 참고하여 아래 내용을 작성해주세요. + +당신의 임무는 매장 정보를 바탕으로, 적은 비용으로 효과를 낼 수 있는 현실적이고 실행 가능한 마케팅 팁을 제안하는 것입니다. +지역성, 지역의 현재 날씨 확인하고, 현재 트렌드까지 고려해주세요. +소상공인을 위한 실용적인 마케팅 팁을 생성해주세요. + +매장 정보: +- 매장명: {store_name} +- 업종: {business_type} +- 위치: {location} +- 좌석 수: {seat_count}석 + +""" + # 🔥 메뉴 정보 추가 + if menu_list and len(menu_list) > 0: + prompt += f"\n메뉴 정보:\n" + for menu in menu_list: + menu_name = menu.get('menu_name', '') + category = menu.get('category', '') + price = menu.get('price', 0) + description = menu.get('description', '') + prompt += f"- {menu_name} ({category}): {price:,}원 - {description}\n" + + prompt += """ +아래 조건을 모두 충족하는 마케팅 팁을 하나 생성해주세요: + +1. **실행 가능성**: 소상공인이 실제로 적용할 수 있는 현실적인 방법 +2. **비용 효율성**: 적은 비용으로 높은 효과를 기대할 수 있는 전략 +3. **구체성**: 실행 단계가 명확하고 구체적일 것 +4. **시의성**: 현재 계절, 유행, 트렌드를 반영 +5. **지역성**: 지역 특성 및 현재 날씨를 고려할 것 + +응답 형식 (300자 내외, 간결하게): +html 형식으로 출력 +핵심 마케팅 팁은 제목없이 한번 더 상단에 보여주세요 +부제목과 내용은 분리해서 출력 +아래의 부제목 앞에는 이모지 포함 +- 핵심 마케팅 팁 (1개) +- 실행 방법 (1개) +- 예상 비용과 기대 효과 +- 주의사항 또는 유의점 +- 참고했던 실제 성공한 마케팅 +- 오늘의 응원의 문장 (간결하게 1개) + +심호흡하고, 단계별로 차근차근 생각해서 정확하고 실현 가능한 아이디어를 제시해주세요. +""" + + return prompt + + def _call_ai_service(self, prompt: str) -> str: + """AI 서비스 호출""" + + # Claude API 우선 시도 + if self.claude_client: + try: + response = self.claude_client.messages.create( + model="claude-3-sonnet-20240229", + max_tokens=1000, + temperature=0.7, + messages=[ + { + "role": "user", + "content": prompt + } + ] + ) + + if response.content and len(response.content) > 0: + logger.info(f"마케팅 팁 생성 완료: {response.content}") + return response.content[0].text.strip() + + except Exception as e: + logger.warning(f"Claude API 호출 실패: {str(e)}") + + # OpenAI API 시도 + if self.openai_client: + try: + response = self.openai_client.chat.completions.create( + model="gpt-3.5-turbo", + messages=[ + { + "role": "system", + "content": "당신은 소상공인을 위한 마케팅 전문가입니다. 실용적이고 구체적인 마케팅 조언을 제공해주세요." + }, + { + "role": "user", + "content": prompt + } + ], + max_tokens=800, + temperature=0.7 + ) + + if response.choices and len(response.choices) > 0: + return response.choices[0].message.content.strip() + + except Exception as e: + logger.warning(f"OpenAI API 호출 실패: {str(e)}") + + # 모든 AI 서비스 호출 실패 + raise Exception("모든 AI 서비스 호출에 실패했습니다.") + + def _create_fallback_tip(self, store_data: Dict[str, Any], additional_requirement: Optional[str]) -> str: + """AI 서비스 실패 시 규칙 기반 Fallback 팁 생성""" + + store_name = store_data.get('store_name', '매장') + business_type = store_data.get('business_type', '') + location = store_data.get('location', '') + menu_list = store_data.get('menu_list', []) + + if menu_list and len(menu_list) > 0: + # 가장 비싼 메뉴 찾기 (시그니처 메뉴로 가정) + expensive_menu = max(menu_list, key=lambda x: x.get('price', 0), default=None) + + # 카테고리별 메뉴 분석 + categories = {} + for menu in menu_list: + category = menu.get('category', '기타') + if category not in categories: + categories[category] = [] + categories[category].append(menu) + + main_category = max(categories.keys(), key=lambda x: len(categories[x])) if categories else '메뉴' + + if expensive_menu: + signature_menu = expensive_menu.get('menu_name', '시그니처 메뉴') + return f"""🎯 {store_name} 메뉴 기반 마케팅 전략 + +💡 핵심 전략: +- SNS를 활용한 홍보 강화 +- 고객 리뷰 관리 및 적극 활용 +- 지역 커뮤니티 참여로 인지도 향상 + +📱 실행 방법: +1. 인스타그램/네이버 블로그 정기 포스팅 +2. 고객 만족도 조사 및 피드백 반영 +3. 주변 상권과의 협력 이벤트 기획 + +💰 예상 효과: 월 매출 10-15% 증가 가능 +⚠️ 주의사항: 꾸준한 실행과 고객 소통이 핵심""" + + # 업종별 기본 팁 + if '카페' in business_type or '커피' in business_type: + return f"""☕ {store_name} 카페 마케팅 전략 + +💡 핵심 포인트: +1. 시그니처 음료 개발 및 SNS 홍보 +2. 계절별 한정 메뉴로 재방문 유도 +3. 인스타그램 포토존 설치 + +📱 실행 방법: +- 매주 신메뉴 또는 이벤트 인스타 포스팅 +- 고객 사진 리포스트로 참여 유도 +- 해시태그 #근처카페 #데이트코스 활용 + +💰 비용: 월 5-10만원 내외 +📈 기대효과: 젊은 고객층 20% 증가""" + + elif '음식점' in business_type or '식당' in business_type: + return f"""🍽️ {store_name} 음식점 마케팅 전략 + +💡 핵심 포인트: +1. 대표 메뉴 스토리텔링 +2. 배달앱 리뷰 관리 강화 +3. 단골 고객 혜택 프로그램 + +📱 실행 방법: +- 요리 과정 영상으로 신뢰도 구축 +- 리뷰 적극 답변으로 고객 관리 +- 방문 횟수별 할인 혜택 제공 + +💰 비용: 월 3-7만원 내외 +📈 기대효과: 재방문율 25% 향상""" + + elif '베이커리' in business_type or '빵집' in business_type: + return f"""🍞 {store_name} 베이커리 마케팅 전략 + +💡 핵심 포인트: +1. 갓 구운 빵 타이밍 알림 서비스 +2. 계절 한정 빵 출시 +3. 포장 디자인으로 선물용 어필 + +📱 실행 방법: +- 네이버 톡톡으로 빵 완성 시간 안내 +- 명절/기념일 특별 빵 한정 판매 +- 예쁜 포장지로 브랜딩 강화 + +💰 비용: 월 5-8만원 내외 +📈 기대효과: 단골 고객 30% 증가""" + + # 지역별 특성 고려 + if location: + location_tip = "" + if '강남' in location or '서초' in location: + location_tip = "\n🏢 강남권 특화: 직장인 대상 점심 세트메뉴 강화" + elif '홍대' in location or '신촌' in location: + location_tip = "\n🎓 대학가 특화: 학생 할인 및 그룹 이벤트 진행" + elif '강북' in location or '노원' in location: + location_tip = "\n🏘️ 주거지역 특화: 가족 단위 고객 대상 패키지 상품" + + return f"""🎯 {store_name} 지역 맞춤 마케팅 + +💡 기본 전략: +- 온라인 리뷰 관리 강화 +- 단골 고객 혜택 프로그램 +- 지역 커뮤니티 참여{location_tip} + +📱 실행 방법: +1. 구글/네이버 지도 정보 최신화 +2. 동네 맘카페 홍보 참여 +3. 주변 상권과 상생 이벤트 + +💰 비용: 월 3-5만원 +📈 기대효과: 인지도 및 매출 향상""" + + # 기본 범용 팁 + return f"""🎯 {store_name} 기본 마케팅 전략 + +💡 핵심 3가지: +1. 온라인 존재감 강화 (SNS, 리뷰 관리) +2. 고객 소통 및 피드백 활용 +3. 차별화된 서비스 제공 + +📱 실행 방법: +- 네이버 플레이스, 구글 정보 최신화 +- 고객 불만 신속 해결로 신뢰 구축 +- 작은 이벤트라도 꾸준히 진행 + +💰 비용: 거의 무료 (시간 투자 위주) +📈 기대효과: 꾸준한 성장과 단골 확보 + +⚠️ 핵심은 지속성입니다!""" diff --git a/smarketing-ai/services/poster_service.py b/smarketing-ai/services/poster_service.py new file mode 100644 index 0000000..c90119c --- /dev/null +++ b/smarketing-ai/services/poster_service.py @@ -0,0 +1,202 @@ +""" +포스터 생성 서비스 V3 +OpenAI DALL-E를 사용한 이미지 생성 (메인 메뉴 이미지 1개 + 프롬프트 내 예시 링크 10개) +""" +import os +from typing import Dict, Any, List +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() + + # 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개 분석 + 예시 링크 7개 프롬프트 제공) + """ + 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, "1024x1536") + + return { + 'success': True, + 'content': image_url, + } + + 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) + } + + def _create_poster_prompt_v3(self, request: PosterContentGetRequest, + main_analysis: Dict[str, Any]) -> str: + """ + 포스터 생성을 위한 AI 프롬프트 생성 (한글, 글자 완전 제외, 메인 이미지 기반 + 예시 링크 7개 포함) + """ + + # 메인 이미지 정보 활용 + main_description = main_analysis.get('description', '맛있는 음식') + main_colors = main_analysis.get('dominant_colors', []) + image_info = main_analysis.get('info', {}) + + # 이미지 크기 및 비율 정보 + 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""" + ## 카페 홍보 포스터 디자인 요청 + + ### 📋 기본 정보 + 카테고리: {request.category} + 콘텐츠 타입: {request.contentType} + 메뉴명: {request.menuName or '없음'} + 메뉴 정보: {main_description} + + ### 📅 이벤트 기간 + 시작일: {request.startDate or '지금'} + 종료일: {request.endDate or '한정 기간'} + 이벤트 시작일과 종료일은 필수로 포스터에 명시해주세요. + + ### 🎨 디자인 요구사항 + 메인 이미지 처리 + - 기존 메인 이미지는 변경하지 않고 그대로 유지 + - 포스터 전체 크기의 1/3 이하로 배치 + - 이미지와 조화로운 작은 장식 이미지 추가 + - 크기: {image_orientation} + + 텍스트 요소 + - 메뉴명 (필수) + - 간단한 추가 홍보 문구 (새로 생성, 한글) 혹은 "{request.requirement or '눈길을 끄는 전문적인 디자인'}"라는 요구사항에 맞는 문구 + - 메뉴명 외 추가되는 문구는 1줄만 작성 + + + 텍스트 배치 규칙 + - 글자가 이미지 경계를 벗어나지 않도록 주의 + - 모서리에 너무 가깝게 배치하지 말 것 + - 적당한 크기로 가독성 확보 + - 아기자기한 한글 폰트 사용 + + ### 🎨 디자인 스타일 + 참조 이미지 + {example_links}의 URL을 참고하여 비슷한 스타일로 제작 + + 색상 가이드 + {color_description} + 전체적인 디자인 방향 + + 타겟: 한국 카페 고객층 + 스타일: 화려하고 매력적인 디자인 + 목적: 소셜미디어 공유용 (적합한 크기) + 톤앤매너: 맛있어 보이는 색상, 방문 유도하는 비주얼 + + ### 🎯 최종 목표 + 고객들이 "이 카페에 가보고 싶다!"라고 생각하게 만드는 시각적으로 매력적인 홍보 포스터 제작 + """ + + return prompt diff --git a/smarketing-ai/services/sns_content_service.py b/smarketing-ai/services/sns_content_service.py new file mode 100644 index 0000000..680a1e7 --- /dev/null +++ b/smarketing-ai/services/sns_content_service.py @@ -0,0 +1,2005 @@ +""" +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.blog_example = [ + { + "raw_html": """
+
+
+
+
+

팔공

중국음식하면 짬뽕이 제일 먼저 저는 떠오릅니다. 어릴 적 부터 짜장은 그닥 좋아하지 않았기에 지금도 짜장 보다는 짬뽕 그리고 볶음밥을 더 사랑합니다.(탕수육도 그닥 좋아하지는 않습니다) 지난 주말 11시30분쯤 갔다가 기겁(?)을 하고 일산으로 갔었던 기억이 납니다. 이날은 평일 조금 늦은 시간이기에 웨이팅이 없겠지 하고 갔습니다. 다행히 웨이팅은 없는데 홀에 딱 한자리가 있어서 다행히 착석을 하고 주문을 합니다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

중화요리 팔공

위치안내: 서울 관악구 남부순환로 1680

영업시간: 11시 20분 ~ 21시 30분( 15시 ~ 17시 브레이크타임, 일요일 휴무)

메뉴: 짜장면, 해물짬뽕, 고기짬뽕, 볶음밥, 탕수육등

+
+
+
+
+
+
+
+
+

3명이 주문한 메뉴는 짜장면, 옛날볶음밥, 팔공해물짬뽕 2개 총 4가지 주문을 합니다.

+
+
+
+
+
+
+
+
+

+
+
+
+ +
+
+
+
50m
지도 데이터
x
© NAVER Corp. /OpenStreetMap
지도 확대
지도 확대/축소 슬라이더
지도 축소

지도 컨트롤러 범례

부동산
거리
읍,면,동
시,군,구
시,도
국가
+ +
+ + +
+
+ +
+
+
+
+

오랜만에 오셨네요 하셔서 " 이젠 와인 못 마시겠네요 "했더니 웃으시더군요 ㅎ

https://blog.naver.com/melburne/222278591313

+
+
+
+ +
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

차림료

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

밑반찬들 ㅎ

요즘 짜사이 주는 곳 참 좋아합니다. 어디였더라? 짜사이가 엄청 맛있었던 곳이 얼마 전 있었는데 음.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

옛날볶음밥(12,000원)

불맛나고 고슬고슬 잘 볶아낸 볶음밥에 바로 볶아서 내어주는 짜장까지 정말이지 훌륭한 볶음밥입니다. 오랜만에 만나다보니 흥문을 ㅎ

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

고슬고슬 기름기 없이 볶아내서 내어주십니다. 3명이서 총 4개의 메뉴를 주문했습니다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

후라이가 아쉽네요. 튀긴 옛날 후라이가 좋은데 아습입니다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

이집 계란국도 헛투루 내어주지 않으십니다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

짜장과 함께 먹는 볶음밥은 역시 굿입니다. 맛나네요.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

짜장면(10.000원)

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

일반짜장면이라고 하기보다는 채소도 큼직한 간짜장이라고 보시는 게 맞을 거 같습니다,.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

면에 짜장이 잘 베이면서 진득한게 끝내주죠. 저는 한 젓가락 조금 얻어서 맛을 봤는데 역시나 좋네요.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

팔공해물짬뽕(13,000원)

최근래 먹은 해물짬뽕 중에서 해산물이 제일 많이 들어 있다고 해야할까요? 큼직큼직하게 들어 있으면서 묵직한 듯 한게 눈으로만 봐도 '맛있겠구나' 라는 생각이 팍팍 들었습니다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

처음 나온 볶음밥은 셋이서 맛나게 먹고 각자의 음식을 탐닉하기 시작합니다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

탱글탱글한 해물들이 어짜피 냉동이겠지만 그래도 싱싱(?)한 듯 맛있습니다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

면발도 좋고 캬~...

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

비싼(?)선동오징어도 푸짐하게 들어있네요. 대왕이, 솔방울 이런 거 없습니다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

맛있는 짬뽕은 해산물부터 국물까지 다 맛있습니다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

줄을 서는 게 무서워서 국물 한방울 안남기고 클리어 했습니다. (국물이 구수하면서 적당히 묵직하고 정말 맛있습니다.)

+
+
+
+ +
+
+
+
+

최종평가: 올해 먹은 짬뽕 중 최고라고 감히 말을 할 수 있을 거 같습니다. 예전보다 더 맛있어 졌으니 사람이 더 많아졌겠죠. 참고로 옛날고기짬뽕은 1시30분전에 솔드아웃된다고 합니다.

+
+
+
+
+
+
+
+
+

+
+
+
+ +
""", + "title": "팔공", + "summary": "중화요리 맛집 홍보" + }, + { + "raw_html": + """
+
+
+
+
+

[남천동 맛집] 안목 - 훌륭한 돼지국밥 한 그릇

미쉐린에 선택한 식당에 특별히 호감이 가는 것은 아니다.

하지만 궁금하기는 하다.

어떤 점에서 좋게 보고 선정을 한 것인지 궁금했다.

내가 가본 식당이라면 판단하면 되겠지만 가보지 않은 식당이라면 그 궁금증은 더 크다.

특히 가장 대중적인 음식이라면 더 클 것이다.

부산의 미쉐린 빕구르망에 2년 연속 선정한 돼지국밥집이 있다.

오가며 보기는 했지만 아직 가보진 못했다.

일부러 찾아가 보았다.

남천동의 "안목"이다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

정문 사진을 찍지 못해서 구글에서 하나 가져왔다. 밖에서 봐도 돼지국밥집 같아 보이지 않는다. 깔끔하고 모던하다.

남천동 등기소 바로 옆 건물이다. 주차장은 별도로 없으니 뒷골목의 주차장을 이용하여야 한다.

그런데 상호의 느낌은 일본풍같이 느껴진다. 혹시 그 뜻을 아시는 분들은 좀 알려주시면 고맙겠다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

좌석은 테이블은 없고 카운터석으로만 되어 있다. 최근 이름난 돼지국밥집들은 다 이런 식으로 만드는 것 같다.

전에 지나다 줄을 서는 것을 보았는데 이날 비가 와서 그랬는지 한가하다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

메뉴가 심플하다. 그냥 돼지국밥에 머릿고기 국밥 정도이다. 수육과 냉제육이 있는데 다음에 가게 되면 먹어보고 싶다.

가격은 비싸지 않은 것은 아닌데 더 비싸지 않아서 다행스럽다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

첨가할 수 있는 여러 가지

+
+
+
+ +
+
+
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+
+
+ +
+
+
+
+

이런 것들이 있는데 마늘만 넣어 먹었다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

내가 주문한 머릿고기 국밥이다. 1인분씩 담겨 나온다.

+
+
+
+ +
+
+
+
+
+ + + +
+
+ + + +
+
+ + + +
+
+
+
+ +
+
+
+
+

머리 위의 선반에 쟁반이 올려져 있으면 그것을 내가 받아서 먹어야 한다. 반찬은 특별한 것은 없는데 이날 풋고추가 맛있었다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

굉장히 뽀얀 국물의 국밥이다. 머릿고기가 올려져 있다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

이것은 아내가 먹은 그냥 돼지국밥이다. 고기만 다른 국밥이다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

국밥에는 간이 되어 있어서 더 넣지 않아도 충분히 먹을 수 있었다. 그러니 다진 양념이나 새우젓은 맛을 보고 첨가하시길....

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

일본 라멘에 넣는 마늘을 짜서 넣는다. 하나 정도면 충분하겠다.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

맛있게 잘 먹었다.

맛있다. 쵸 근래 너무 저가의 돼지국밥만 먹고 다녀서인지 안목의 국밥은 맛있었다.

국물이 너무 무겁지도 않으면서도 진득했다.

완성도가 높다. 국물은 손가락에 꼽을 정도로 괜찮았다.

고기의 품질도 좋았고 손질도 잘했다. 부드럽고 또 비계 부분은 쫄깃했다.

다만 고기가 많아 보이지만 한 점 한 점이 굉장히 얇아서 무게로 치면 그렇게 많은 양은 아닐 것이다.

그리고 국밥 전체적으로 양은 그다지 많은 편은 아니다.

이 정도의 맛이면 미쉐린 빕구르망에 선정되는 것인지는 모르겠지만 나로서는 충분하다고 느껴진다.

내가 추구하는 수더분하고 푸짐한 국밥하고는 반대편에 있는 국밥이지만 완성도가 높으니 다 괜찮아 보인다.

좀 편하게 갈 수 있다면 가끔 가고 싶다.

서면과 부산역에 분점이 있다고 하니 그곳이 좀 편하겠다.

+
+
+
+ +
+
+
+
50m
지도 데이터
x
© NAVER Corp. /OpenStreetMap
지도 확대
지도 확대/축소 슬라이더
지도 축소

지도 컨트롤러 범례

부동산
거리
읍,면,동
시,군,구
시,도
국가
+ +
+ + +
+
+ +
+
+
+
+

+
+
+
+ +
""", + "title": "안목", + "summary": "국밥 맛집 홍보" + }, + { + "raw_html": """
+
+
+
+
+ + + +
+
+
+
+
+
+
+

서울 미쉐린맛집 한식전문 목멱산방

-투쁠한우 육회비빔밥

-검은깨두부 보쌈

+
+
+
+
+
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

서울 중구 퇴계로20길 71

영업시간

매일

11:00 - 20:00

라스트오더

19:20

전화

02-318-4790

+
+
+
+
+
+
+
+
+

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

서울 남산은 참 묘한 매력이 있는 곳 같아요!

도시 속인데도 한 발짝만 올라오면

바람도 다르고, 공기도 다르고,

마음까지 탁 트이는 그런 느낌!

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

그런 남산 한켠에 있는

서울 미쉐린 맛집

목멱산방 본점에서

특별한 한 끼를 즐기고 왔어요!

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

식사 중간중간 보니 외국인 관광객도 많았고

데이트나 가족 외식으로 많이들 오더라고요~

실내는 군더더기 없이 깔끔하고

모던한 느낌이라

전통 한식을

더 세련되게 느낄 수 있어요.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

주문은 셀프 방식으로

키오스크로 하면돼요~

방송에도 여러번 나오고

미쉐린 맛집답게

주말에는 사람이 많아요!

+
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

이날 저희가 선택한 메뉴는

검은깨두부와 보쌈,

그리고

시그니처 메뉴인

투뿔한우 육회비빔밥을

주문했는데

기대 이상이었어요!

+
+
+
+ +
+
+
+
+

검은깨두부&보쌈

+
+
+
+
+
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

먼저 검은깨두부와 보쌈!!

검은깨 두부는

보기만 해도

고소한 향이 물씬 풍기는것같고

입에 넣자마자 사르르 녹아요!!

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

정말 진한 고소함이 입안에 퍼지는데,

이게 그냥 두부가 아니라는 걸

한입만 먹어도 느낄 수 있어요.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

그 두부와 함께 나오는 보쌈은

지방과 살코기 비율이 완벽해서

쫀득하면서도 부드러워요.

거기에 곁들여지는

볶음김치와 특제 야채무침이

보쌈 맛을 확 살려줘서,

딱 한식의 진수라는 말이

떠오르더라고요!

+
+
+
+ +
+
+
+
+

투쁠한우 육회비빔밥

+
+
+
+
+
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

대망의 투쁠한우 육회비빔밥!

비주얼도 예쁘고

정말 먹음직 스러웠어요!

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

이건 먼저 육회만 따로 맛봤는데,

신선한 투뿔 채끝살에

유자청과 꿀로 살짝 단맛을 더한

양념이 어우러져,

하나도 느끼하지 않고 깔끔했어요.!!

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

비빔밥은 나물과 함께

조심스럽게 비벼 한입 먹었을 때,

고추장을 넣지 않고도

양념된 육회와 참기름만으로

깊은 맛이 나는 게,

정말 재료 하나하나에

얼마나 정성을 들였는지

알겠더라고요.

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

비빔밥 안에 들어가는 나물도

건나물, 생야채, 표고버섯,

도라지, 고사리 등

제철에 맞춰 엄선된

나물들이 들어가는데,

하나하나 다 본연의 맛이 좋았어요~

+
+
+
+ +
+
+
+
+ + + +
+
+
+ +
+
+
+
+
+

삼광쌀로 지은 밥도 맛있더라구요~

밥 한 숟가락에

입안이 꽉 차는 느낌이 넘 좋았어요!

+
+
+
+ +
+
+
+
+
+ + + +
+
+ + + +
+
+
+
+ +
+
+
+
+

함께 주문하고 싶은 사이드 메뉴는

바로 치즈김치전!

피자치즈와 모짜렐라가

가득 들어간 김치전인데,

겉은 바삭하고 속은 촉촉한 게

비빔밥이랑 궁합 최고예요.

+
+
+
+ +
+
+
+
+
+ + + +
+
+ + + +
+
+
+
+ +
+
+
+
+

술 한잔 곁들이고 싶다면,

비빔밥 전용 막걸리도 있어요.

‘한 잔 막걸리’라는 이름답게

식전–식중–식후로 나눠 마시는 재미가 있어요.

과일향도 은은하고,

단맛과 신맛이 균형 잡혀 있어서

비빔밥과 찰떡이에요.

남산 산책하다가,

혹은 명동역 근처로

들리기 좋은 곳이랍니다^^

+
+
+
+ +
+
+
+
50m
지도 데이터
x
© NAVER Corp. /OpenStreetMap
지도 확대
지도 확대/축소 슬라이더
지도 축소

지도 컨트롤러 범례

부동산
거리
읍,면,동
시,군,구
시,도
국가
+ +
+ +
+
+ +
+
+
+
+

+
+
+
+ +
""", + "title": "목멱산방", + "summary": "한식 맛집 홍보" + } + ] + # 인스타 글 예시 + self.insta_example = [ + { + "caption": """힘든 월요일 잘 이겨내신 여러분~~~ + 소나기도 내리고 힘드셨을텐데 + 오늘 하루 고생 많으셨어요~~^^ + 고생한 나를 위해 시원한 맥주에 + 낙곱새~~기가 막히죠??낙지에 대창올리고 + 그 위에 새우~화룡점정으로 생와사비~ + 그 맛은 뭐 말씀 안드려도 여러분들이 + 더 잘 아실거예요~~그럼 다들 낙곱새 고고~~""", + "title": "국민 낙곱새", + "summary": "낙곱새 맛집 홍보" + }, + { + "caption": """안녕하세요! 타코몰리김포점입니다! + 타코몰리는 멕시코 문화와 풍부한맛을 경험할 수 있는 특별한 공간입니다.🎉 + + 🌶 대표 메뉴를 맛보세요 + 수제 타코, 바삭한 퀘사디아, 풍성한 부리또로 다양한 맛을 즐길 수 있습니다. + + 📸 특별한 순간을 담아보세요 + #타코몰리김포 해시태그와 함께 여러분의 멋진 사진을 공유해주세요. + 이벤트가 기다리고 있답니다!! + (새우링/치즈스틱/음료 택1) + + 📍 위치 + 김포한강 11로 140번길 15-2 + + 멕시코의 맛과 전통에 푹 빠져보세요! + 언제든지 여러분을 기다리고 있겠습니다🌟""", + "title": "타코몰리", + "summary": "멕시칸 맛집 홍보" + }, + { + "caption":"""📣명륜진사갈비 신메뉴 3종 출시! + + 특제 고추장 양념에 마늘과 청양고추를 더해 + 매콤한 불맛이 일품인 #매콤불고기 🌶️ + + 특제 간장 양념에 마늘과 청양고추를 더해 + 달콤한 감칠맛이 있는 #달콤불고기 🍯 + + 갈비뼈에 붙어있는 부위로 일반 삼겹살보다 + 더욱 깊은 맛과 풍미를 가진 #삼겹갈비 까지🍖 + + 신메뉴로 더욱 풍성해진 명륜진사갈비에서 + 연말 가족/단체모임을 즐겨보세요! + + ※ 신메뉴는 지점에 따라 탄력적으로 운영되고 있으니, + 자세한 문의는 방문하실 매장으로 확인 부탁드립니다.""", + "title": "명륜진사갈비", + "summary": "갈비 맛집 홍보" + } + ] + + # 플랫폼별 콘텐츠 특성 정의 (대폭 개선) + 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) + + # blog_example을 프롬프트에 추가 + if request.platform == '네이버 블로그' and hasattr(self, 'blog_example') and self.blog_example: + prompt += f"\n\n**참고 예시:**\n{str(self.blog_example)}\n위 예시를 참고하여 점주의 입장에서 가게 홍보 게시물을 작성해주세요." + elif hasattr(self, 'insta_example') and self.insta_example : + prompt += f"\n\n**참고 예시:**\n{str(self.insta_example)}\n위 예시를 참고하여 점주의 입장에서 가게 홍보 게시물을 작성해주세요." + + # 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, image_descriptions) + elif request.platform == '네이버 블로그': + return self._create_naver_blog_prompt(request, platform_spec, image_descriptions, + image_placement_plan) + else: + return self._create_instagram_prompt(request, platform_spec, image_descriptions) + + def _create_instagram_prompt(self, request: SnsContentGetRequest, platform_spec: dict, + image_descriptions: list) -> str: + """ + 인스타그램 특화 프롬프트 + """ + category_hashtags = self.category_keywords.get(request.category, {}).get('인스타그램', []) + + prompt = f""" +당신은 인스타그램 마케팅 전문가입니다. 소상공인 음식점을 위한 매력적인 인스타그램 게시물을 작성해주세요. +**🍸 가게 정보:** +- 가게명: {request.storeName} +- 업종 : {request.storeType} + +**🎯 콘텐츠 정보:** +- 제목: {request.title} +- 카테고리: {request.category} +- 콘텐츠 타입: {request.contentType} +- 메뉴명: {request.menuName or '특별 메뉴'} +- 이벤트: {request.eventName or '특별 이벤트'} +- 독자층: {request.target} + +**📱 인스타그램 특화 요구사항:** +- 글 구조: {platform_spec['content_structure']} +- 최대 길이: {platform_spec['max_length']}자 +- 해시태그: {platform_spec['hashtag_count']}개 내외 + +**✨ 인스타그램 작성 가이드라인:** +{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, + 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.storeName} +- 업종 : {request.storeType} + +**📝 콘텐츠 정보:** +- 제목: {request.title} +- 카테고리: {request.category} +- 콘텐츠 타입: {request.contentType} +- 메뉴명: {request.menuName or '대표 메뉴'} +- 이벤트: {request.eventName or '특별 이벤트'} +- 독자층: {request.target} + +**🔍 네이버 블로그 특화 요구사항:** +- 글 구조: {platform_spec['content_structure']} +- 최대 길이: {platform_spec['max_length']}자 +- 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. 검색자의 궁금증을 해결하는 정보 중심 작성 +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. 인스타그램인 경우 첫 번째 이미지를 맨 위에 배치 ⭐ 새로 추가! + images_html_content = "" + if request.platform == '인스타그램' and request.images and len(request.images) > 0: + # 모든 이미지를 통일된 크기로 HTML 변환 (한 줄로 작성!) + for i, image_url in enumerate(request.images): + # ⭐ 핵심: 모든 HTML을 한 줄로 작성해서
변환 문제 방지 + image_html = f'
이미지 {i + 1}
' + images_html_content += image_html + "\n" + + # 이미지를 콘텐츠 맨 앞에 추가 + content = images_html_content + content + + # 2. 네이버 블로그인 경우 이미지 태그를 실제 이미지로 변환 + elif request.platform == '네이버 블로그' and image_placement_plan: + content = self._replace_image_tags_with_html(content, image_placement_plan, request.images) + + # 3. 실제 줄바꿈을
태그로 변환 + content = content.replace('\n', '
') + + # 4. 추가 정리: \r, 여러 공백 정리 + content = content.replace('\\r', '').replace('\r', '') + + # 6. 여러 개의
태그를 하나로 정리 + import re + content = re.sub(r'(
\s*){3,}', '

', content) + + # 7. ⭐ 간단한 해시태그 스타일링 (CSS 충돌 방지) + import re + # style="..." 패턴을 먼저 찾아서 보호 + style_patterns = re.findall(r'style="[^"]*"', content) + protected_content = content + + for i, pattern in enumerate(style_patterns): + protected_content = protected_content.replace(pattern, f'___STYLE_{i}___') + + # 이제 안전하게 해시태그 스타일링 + protected_content = re.sub(r'(#[\w가-힣]+)', r'\1', + protected_content) + + # 보호된 스타일 복원 + for i, pattern in enumerate(style_patterns): + protected_content = protected_content.replace(f'___STYLE_{i}___', pattern) + + content = protected_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""" +
+
+

{request.platform} 게시물

+
+
+
+ {content} +
+ {self._add_metadata_html(request)} +
+
+ """ + 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""" +
+ 이미지 +
+ {image_description} +
+
""" + + # 콘텐츠에서 태그 교체 + content = content.replace(f'[IMAGE_{tag}]', image_html) + + return content + + def _add_metadata_html(self, request: SnsContentGetRequest) -> str: + """ + 메타데이터를 HTML에 추가 + """ + metadata_html = '
' + + if request.menuName: + metadata_html += f'
메뉴: {request.menuName}
' + + if request.eventName: + metadata_html += f'
이벤트: {request.eventName}
' + + if request.startDate and request.endDate: + metadata_html += f'
기간: {request.startDate} ~ {request.endDate}
' + + metadata_html += f'
카테고리: {request.category}
' + metadata_html += f'
플랫폼: {request.platform}
' + metadata_html += f'
생성일: {datetime.now().strftime("%Y-%m-%d %H:%M")}
' + metadata_html += '
' + + return metadata_html diff --git a/smarketing-ai/test.py b/smarketing-ai/test.py new file mode 100644 index 0000000..019f76a --- /dev/null +++ b/smarketing-ai/test.py @@ -0,0 +1,42 @@ +""" +마케팅 팁 API 테스트 스크립트 +""" +import requests +import json + + +def test_marketing_tip_api(): + """마케팅 팁 API 테스트""" + + # 테스트 데이터 + test_data = { + "store_name": "더블샷 카페", + "business_type": "카페", + "location": "서울시 강남구 역삼동", + "seat_count": 30, + } + + # API 호출 + url = "http://localhost:5001/api/v1/generate-marketing-tip" + headers = { + "Content-Type": "application/json", + "Authorization": "Bearer dummy-key" + } + + try: + response = requests.post(url, json=test_data, headers=headers) + + print(f"Status Code: {response.status_code}") + print(f"Response: {json.dumps(response.json(), ensure_ascii=False, indent=2)}") + + if response.status_code == 200: + print("✅ 테스트 성공!") + else: + print("❌ 테스트 실패!") + + except Exception as e: + print(f"❌ 테스트 오류: {str(e)}") + + +if __name__ == "__main__": + test_marketing_tip_api() \ No newline at end of file diff --git a/smarketing-ai/utils/__init__.py b/smarketing-ai/utils/__init__.py new file mode 100644 index 0000000..6ae5294 --- /dev/null +++ b/smarketing-ai/utils/__init__.py @@ -0,0 +1 @@ +# Package initialization file diff --git a/smarketing-ai/utils/ai_client.py b/smarketing-ai/utils/ai_client.py new file mode 100644 index 0000000..68700d3 --- /dev/null +++ b/smarketing-ai/utils/ai_client.py @@ -0,0 +1,237 @@ +""" +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 +from utils.blob_storage import BlobStorageClient + + +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') + + # Blob Storage 클라이언트 초기화 + self.blob_client = BlobStorageClient() + + # 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 = "1024x1536") -> str: + """ + gpt를 사용하여 이미지 생성 + Args: + prompt: 이미지 생성 프롬프트 + size: 이미지 크기 (1024x1536) + Returns: + Azure Blob Storage에 저장된 이미지 URL + """ + try: + if not self.openai_client: + raise Exception("OpenAI API 키가 설정되지 않았습니다.") + + response = self.openai_client.images.generate( + model="gpt-image-1", + prompt=prompt, + size=size, + n=1, + ) + + # base64 이미지 데이터 추출 + b64_data = response.data[0].b64_json + image_data = base64.b64decode(b64_data) + + # Azure Blob Storage에 업로드 + blob_url = self.blob_client.upload_image(image_data, 'png') + + print(f"✅ 이미지 생성 및 업로드 완료: {blob_url}") + return blob_url + + except Exception as 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 "안녕하세요! 우리 가게를 찾아주셔서 감사합니다." diff --git a/smarketing-ai/utils/blob_storage.py b/smarketing-ai/utils/blob_storage.py new file mode 100644 index 0000000..3018a8b --- /dev/null +++ b/smarketing-ai/utils/blob_storage.py @@ -0,0 +1,117 @@ +""" +Azure Blob Storage 유틸리티 +이미지 업로드 및 URL 생성 기능 제공 +""" +import os +from datetime import datetime +from typing import Optional +from azure.storage.blob import BlobServiceClient, ContentSettings +from config.config import Config + + +class BlobStorageClient: + """Azure Blob Storage 클라이언트 클래스""" + + def __init__(self): + """Blob Storage 클라이언트 초기화""" + self.account_name = Config.AZURE_STORAGE_ACCOUNT_NAME + self.account_key = Config.AZURE_STORAGE_ACCOUNT_KEY + self.container_name = Config.AZURE_STORAGE_CONTAINER_NAME + + if not self.account_key: + raise ValueError("Azure Storage Account Key가 설정되지 않았습니다.") + + # Connection String 생성 + connection_string = f"DefaultEndpointsProtocol=https;AccountName={self.account_name};AccountKey={self.account_key};EndpointSuffix=core.windows.net" + + # Blob Service Client 초기화 + self.blob_service_client = BlobServiceClient.from_connection_string(connection_string) + + def upload_image(self, image_data: bytes, file_extension: str = 'png') -> str: + """ + 이미지를 Blob Storage에 업로드 + + Args: + image_data: 업로드할 이미지 바이트 데이터 + file_extension: 파일 확장자 (기본값: 'png') + + Returns: + 업로드된 이미지의 Public URL + """ + try: + # 파일명 생성: poster_YYYYMMDDHHMMSS.png + timestamp = datetime.now().strftime('%Y%m%d%H%M%S') + blob_name = f"poster_{timestamp}.{file_extension}" + + # Content Type 설정 + content_settings = ContentSettings(content_type=f'image/{file_extension}') + + # Blob 업로드 + blob_client = self.blob_service_client.get_blob_client( + container=self.container_name, + blob=blob_name + ) + + blob_client.upload_blob( + image_data, + content_settings=content_settings, + overwrite=True + ) + + # Public URL 생성 + blob_url = f"https://{self.account_name}.blob.core.windows.net/{self.container_name}/{blob_name}" + + print(f"✅ 이미지 업로드 완료: {blob_url}") + return blob_url + + except Exception as e: + print(f"❌ Blob Storage 업로드 실패: {str(e)}") + raise Exception(f"이미지 업로드 실패: {str(e)}") + + def upload_file(self, file_path: str) -> str: + """ + 로컬 파일을 Blob Storage에 업로드 + + Args: + file_path: 업로드할 로컬 파일 경로 + + Returns: + 업로드된 파일의 Public URL + """ + try: + # 파일 확장자 추출 + file_extension = os.path.splitext(file_path)[1][1:].lower() + + # 파일 읽기 + with open(file_path, 'rb') as file: + file_data = file.read() + + # 업로드 + return self.upload_image(file_data, file_extension) + + except Exception as e: + print(f"❌ 파일 업로드 실패: {str(e)}") + raise Exception(f"파일 업로드 실패: {str(e)}") + + def delete_blob(self, blob_name: str) -> bool: + """ + Blob 삭제 + + Args: + blob_name: 삭제할 Blob 이름 + + Returns: + 삭제 성공 여부 + """ + try: + blob_client = self.blob_service_client.get_blob_client( + container=self.container_name, + blob=blob_name + ) + blob_client.delete_blob() + print(f"✅ Blob 삭제 완료: {blob_name}") + return True + + except Exception as e: + print(f"❌ Blob 삭제 실패: {str(e)}") + return False \ No newline at end of file diff --git a/smarketing-ai/utils/image_processor.py b/smarketing-ai/utils/image_processor.py new file mode 100644 index 0000000..176c10a --- /dev/null +++ b/smarketing-ai/utils/image_processor.py @@ -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 \ No newline at end of file diff --git a/smarketing-java/.gitignore b/smarketing-java/.gitignore new file mode 100644 index 0000000..c2065bc --- /dev/null +++ b/smarketing-java/.gitignore @@ -0,0 +1,37 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/smarketing-java/ai-recommend/build.gradle b/smarketing-java/ai-recommend/build.gradle new file mode 100644 index 0000000..771a2fc --- /dev/null +++ b/smarketing-java/ai-recommend/build.gradle @@ -0,0 +1,4 @@ +dependencies { + implementation project(':common') + runtimeOnly 'com.mysql:mysql-connector-j' +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/AIRecommendServiceApplication.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/AIRecommendServiceApplication.java new file mode 100644 index 0000000..c331ea3 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/AIRecommendServiceApplication.java @@ -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); + } +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java new file mode 100644 index 0000000..49b2801 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java @@ -0,0 +1,168 @@ +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.MenuData; +import com.won.smarketing.recommend.domain.model.StoreData; +import com.won.smarketing.recommend.domain.model.StoreWithMenuData; +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.presentation.dto.MarketingTipResponse; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional +public class MarketingTipService implements MarketingTipUseCase { + + private final MarketingTipRepository marketingTipRepository; + private final StoreDataProvider storeDataProvider; + private final AiTipGenerator aiTipGenerator; + + @Override + public MarketingTipResponse provideMarketingTip() { + String userId = getCurrentUserId(); + log.info("마케팅 팁 제공: userId={}", userId); + + try { + // 1. 사용자의 매장 정보 조회 + StoreWithMenuData storeWithMenuData = storeDataProvider.getStoreWithMenuData(userId); + + // 2. 1시간 이내에 생성된 마케팅 팁이 있는지 DB에서 확인 + Optional recentTip = findRecentMarketingTip(storeWithMenuData.getStoreData().getStoreId()); + + if (recentTip.isPresent()) { + log.info("1시간 이내에 생성된 마케팅 팁 발견: tipId={}", recentTip.get().getId().getValue()); + log.info("1시간 이내에 생성된 마케팅 팁 발견: getTipContent()={}", recentTip.get().getTipContent()); + return convertToResponse(recentTip.get(), storeWithMenuData.getStoreData(), true); + } + + // 3. 1시간 이내 팁이 없으면 새로 생성 + log.info("1시간 이내 마케팅 팁이 없어 새로 생성합니다: userId={}, storeId={}", userId, storeWithMenuData.getStoreData().getStoreId()); + MarketingTip newTip = createNewMarketingTip(storeWithMenuData); + return convertToResponse(newTip, storeWithMenuData.getStoreData(), false); + + } catch (Exception e) { + log.error("마케팅 팁 조회/생성 중 오류: userId={}", userId, e); + throw new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR); + } + } + + /** + * DB에서 1시간 이내 생성된 마케팅 팁 조회 + */ + private Optional findRecentMarketingTip(Long storeId) { + log.debug("DB에서 1시간 이내 마케팅 팁 조회: storeId={}", storeId); + + // 최근 생성된 팁 1개 조회 + Pageable pageable = PageRequest.of(0, 1); + Page recentTips = marketingTipRepository.findByStoreIdOrderByCreatedAtDesc(storeId, pageable); + + if (recentTips.isEmpty()) { + log.debug("매장의 마케팅 팁이 존재하지 않음: storeId={}", storeId); + return Optional.empty(); + } + + MarketingTip mostRecentTip = recentTips.getContent().get(0); + LocalDateTime oneHourAgo = LocalDateTime.now().minusHours(1); + + // 1시간 이내에 생성된 팁인지 확인 + if (mostRecentTip.getCreatedAt().isAfter(oneHourAgo)) { + log.debug("1시간 이내 마케팅 팁 발견: tipId={}, 생성시간={}", + mostRecentTip.getId().getValue(), mostRecentTip.getCreatedAt()); + return Optional.of(mostRecentTip); + } + + log.debug("가장 최근 팁이 1시간 이전에 생성됨: tipId={}, 생성시간={}", + mostRecentTip.getId().getValue(), mostRecentTip.getCreatedAt()); + return Optional.empty(); + } + + /** + * 새로운 마케팅 팁 생성 + */ + private MarketingTip createNewMarketingTip(StoreWithMenuData storeWithMenuData) { + log.info("새로운 마케팅 팁 생성 시작: storeName={}", storeWithMenuData.getStoreData().getStoreName()); + + // AI 서비스로 팁 생성 + String aiGeneratedTip = aiTipGenerator.generateTip(storeWithMenuData); + log.debug("AI 팁 생성 완료: {}", aiGeneratedTip.substring(0, Math.min(50, aiGeneratedTip.length()))); + + // 도메인 객체 생성 및 저장 + MarketingTip marketingTip = MarketingTip.builder() + .storeId(storeWithMenuData.getStoreData().getStoreId()) + .tipContent(aiGeneratedTip) + .storeWithMenuData(storeWithMenuData) + .createdAt(LocalDateTime.now()) + .build(); + + MarketingTip savedTip = marketingTipRepository.save(marketingTip); + log.info("새로운 마케팅 팁 저장 완료: tipId={}", savedTip.getId().getValue()); + log.info("새로운 마케팅 팁 저장 완료: savedTip.getTipContent()={}", savedTip.getTipContent()); + + return savedTip; + } + + /** + * 마케팅 팁을 응답 DTO로 변환 (전체 내용 포함) + */ + private MarketingTipResponse convertToResponse(MarketingTip marketingTip, StoreData storeData, boolean isRecentlyCreated) { + String tipSummary = generateTipSummary(marketingTip.getTipContent()); + + return MarketingTipResponse.builder() + .tipId(marketingTip.getId().getValue()) + .tipSummary(tipSummary) + .tipContent(marketingTip.getTipContent()) // 🆕 전체 내용 포함 + .storeInfo(MarketingTipResponse.StoreInfo.builder() + .storeName(storeData.getStoreName()) + .businessType(storeData.getBusinessType()) + .location(storeData.getLocation()) + .build()) + .createdAt(marketingTip.getCreatedAt()) + .updatedAt(marketingTip.getUpdatedAt()) + .isRecentlyCreated(isRecentlyCreated) + .build(); + } + + /** + * 마케팅 팁 요약 생성 (첫 50자 또는 첫 번째 문장) + */ + private String generateTipSummary(String fullContent) { + if (fullContent == null || fullContent.trim().isEmpty()) { + return "마케팅 팁이 생성되었습니다."; + } + + // 첫 번째 문장으로 요약 (마침표 기준) + String[] sentences = fullContent.split("[.!?]"); + String firstSentence = sentences.length > 0 ? sentences[0].trim() : fullContent; + + // 50자 제한 + if (firstSentence.length() > 50) { + return firstSentence.substring(0, 47) + "..."; + } + + return firstSentence; + } + + /** + * 현재 로그인된 사용자 ID 조회 + */ + private String getCurrentUserId() { + return SecurityContextHolder.getContext().getAuthentication().getName(); + } +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/usecase/MarketingTipUseCase.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/usecase/MarketingTipUseCase.java new file mode 100644 index 0000000..209be1d --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/usecase/MarketingTipUseCase.java @@ -0,0 +1,12 @@ +package com.won.smarketing.recommend.application.usecase; + +import com.won.smarketing.recommend.presentation.dto.MarketingTipResponse; + +public interface MarketingTipUseCase { + + /** + * 마케팅 팁 제공 + * 1시간 이내 팁이 있으면 기존 것 사용, 없으면 새로 생성 + */ + MarketingTipResponse provideMarketingTip(); +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/CacheConfig.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/CacheConfig.java new file mode 100644 index 0000000..8dec201 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/CacheConfig.java @@ -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 캐시 사용 +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/JpaConfig.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/JpaConfig.java new file mode 100644 index 0000000..de705f5 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/JpaConfig.java @@ -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 { +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/WebClientConfig.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/WebClientConfig.java new file mode 100644 index 0000000..f860937 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/config/WebClientConfig.java @@ -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, 10000) + .responseTimeout(Duration.ofMillis(30000)); + + return WebClient.builder() + .clientConnector(new ReactorClientHttpConnector(httpClient)) + .codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(1024 * 1024)) + .build(); + } +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/MarketingTip.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/MarketingTip.java new file mode 100644 index 0000000..28e503d --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/MarketingTip.java @@ -0,0 +1,36 @@ +package com.won.smarketing.recommend.domain.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.cglib.core.Local; + +import java.time.LocalDateTime; + +/** + * 마케팅 팁 도메인 모델 (날씨 정보 제거) + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class MarketingTip { + + private TipId id; + private Long storeId; + private String tipSummary; + private String tipContent; + private StoreWithMenuData storeWithMenuData; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public static MarketingTip create(Long storeId, String tipContent, StoreWithMenuData storeWithMenuData) { + return MarketingTip.builder() + .storeId(storeId) + .tipContent(tipContent) + .storeWithMenuData(storeWithMenuData) + .createdAt(LocalDateTime.now()) + .build(); + } +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/MenuData.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/MenuData.java new file mode 100644 index 0000000..2ec3eea --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/MenuData.java @@ -0,0 +1,21 @@ +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 MenuData { + private Long menuId; + private String menuName; + private String category; + private Integer price; + private String description; +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/StoreData.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/StoreData.java new file mode 100644 index 0000000..efdcb8d --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/StoreData.java @@ -0,0 +1,22 @@ +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 Long storeId; + private String storeName; + private String businessType; + private String location; + private String description; + private Integer seatCount; +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/StoreWithMenuData.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/StoreWithMenuData.java new file mode 100644 index 0000000..15ba3aa --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/StoreWithMenuData.java @@ -0,0 +1,13 @@ +package com.won.smarketing.recommend.domain.model; + +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +@Data +@Builder +public class StoreWithMenuData { + private StoreData storeData; + private List menuDataList; +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/TipId.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/TipId.java new file mode 100644 index 0000000..47808cb --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/model/TipId.java @@ -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); + } +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/repository/MarketingTipRepository.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/repository/MarketingTipRepository.java new file mode 100644 index 0000000..ce0be77 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/repository/MarketingTipRepository.java @@ -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 findById(Long tipId); + + Page findByStoreIdOrderByCreatedAtDesc(Long storeId, Pageable pageable); +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/AiTipGenerator.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/AiTipGenerator.java new file mode 100644 index 0000000..f3ea48e --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/AiTipGenerator.java @@ -0,0 +1,18 @@ +package com.won.smarketing.recommend.domain.service; + +import com.won.smarketing.recommend.domain.model.StoreData; +import com.won.smarketing.recommend.domain.model.StoreWithMenuData; + +/** + * AI 팁 생성 도메인 서비스 인터페이스 (단순화) + */ +public interface AiTipGenerator { + + /** + * Python AI 서비스를 통한 마케팅 팁 생성 + * + * @param storeWithMenuData 매장 및 메뉴 정보 + * @return AI가 생성한 마케팅 팁 + */ + String generateTip(StoreWithMenuData storeWithMenuData); +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/StoreDataProvider.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/StoreDataProvider.java new file mode 100644 index 0000000..d7935e6 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/domain/service/StoreDataProvider.java @@ -0,0 +1,13 @@ +package com.won.smarketing.recommend.domain.service; + +import com.won.smarketing.recommend.domain.model.StoreWithMenuData; + +import java.util.List; + +/** + * 매장 데이터 제공 도메인 서비스 인터페이스 + */ +public interface StoreDataProvider { + + StoreWithMenuData getStoreWithMenuData(String userId); +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/PythonAiTipGenerator.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/PythonAiTipGenerator.java new file mode 100644 index 0000000..e091fc5 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/PythonAiTipGenerator.java @@ -0,0 +1,143 @@ +package com.won.smarketing.recommend.infrastructure.external; + +import com.won.smarketing.recommend.domain.model.MenuData; +import com.won.smarketing.recommend.domain.model.StoreData; +import com.won.smarketing.recommend.domain.model.StoreWithMenuData; +import com.won.smarketing.recommend.domain.service.AiTipGenerator; +import lombok.Getter; +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.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 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(StoreWithMenuData storeWithMenuData) { + try { + log.debug("Python AI 서비스 직접 호출: store={}", storeWithMenuData.getStoreData().getStoreName()); + return callPythonAiService(storeWithMenuData); + + } catch (Exception e) { + log.error("Python AI 서비스 호출 실패, Fallback 처리: {}", e.getMessage()); + return createFallbackTip(storeWithMenuData); + } + } + + private String callPythonAiService(StoreWithMenuData storeWithMenuData) { + + try { + + StoreData storeData = storeWithMenuData.getStoreData(); + List menuDataList = storeWithMenuData.getMenuDataList(); + + // 메뉴 데이터를 Map 형태로 변환 + List> menuList = menuDataList.stream() + .map(menu -> { + Map menuMap = new HashMap<>(); + menuMap.put("menu_id", menu.getMenuId()); + menuMap.put("menu_name", menu.getMenuName()); + menuMap.put("category", menu.getCategory()); + menuMap.put("price", menu.getPrice()); + menuMap.put("description", menu.getDescription()); + return menuMap; + }) + .collect(Collectors.toList()); + + // Python AI 서비스로 전송할 데이터 (매장 정보 + 메뉴 정보) + Map requestData = new HashMap<>(); + requestData.put("store_name", storeData.getStoreName()); + requestData.put("business_type", storeData.getBusinessType()); + requestData.put("location", storeData.getLocation()); + requestData.put("seat_count", storeData.getSeatCount()); + requestData.put("menu_list", menuList); + + 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(storeWithMenuData); + } + + /** + * 규칙 기반 Fallback 팁 생성 (날씨 정보 없이 매장 정보만 활용) + */ + private String createFallbackTip(StoreWithMenuData storeWithMenuData) { + String businessType = storeWithMenuData.getStoreData().getBusinessType(); + String storeName = storeWithMenuData.getStoreData().getStoreName(); + String location = storeWithMenuData.getStoreData().getLocation(); + + // 업종별 기본 팁 생성 + 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); + } + + @Getter + private static class PythonAiResponse { + private String tip; + private String status; + private String message; + private LocalDateTime generatedTip; + private String businessType; + private String aiModel; + } +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/StoreApiDataProvider.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/StoreApiDataProvider.java new file mode 100644 index 0000000..cc4f682 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/StoreApiDataProvider.java @@ -0,0 +1,311 @@ +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.MenuData; +import com.won.smarketing.recommend.domain.model.StoreData; +import com.won.smarketing.recommend.domain.model.StoreWithMenuData; +import com.won.smarketing.recommend.domain.service.StoreDataProvider; +import jakarta.servlet.http.HttpServletRequest; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientException; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 매장 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; + + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String BEARER_PREFIX = "Bearer "; + + public StoreWithMenuData getStoreWithMenuData(String userId) { + log.info("매장 정보와 메뉴 정보 통합 조회 시작: userId={}", userId); + + try { + // 매장 정보와 메뉴 정보를 병렬로 조회 + StoreData storeData = getStoreDataByUserId(userId); + List menuDataList = getMenusByStoreId(storeData.getStoreId()); + + StoreWithMenuData result = StoreWithMenuData.builder() + .storeData(storeData) + .menuDataList(menuDataList) + .build(); + + log.info("매장 정보와 메뉴 정보 통합 조회 완료: storeId={}, storeName={}, menuCount={}", + storeData.getStoreId(), storeData.getStoreName(), menuDataList.size()); + + return result; + + } catch (Exception e) { + log.error("매장 정보와 메뉴 정보 통합 조회 실패, Mock 데이터 반환: storeId={}", userId, e); + + // 실패 시 Mock 데이터 반환 + return StoreWithMenuData.builder() + .storeData(createMockStoreData(userId)) + .menuDataList(createMockMenuData(6L)) + .build(); + } + } + + public StoreData getStoreDataByUserId(String userId) { + try { + log.debug("매장 정보 실시간 조회: userId={}", userId); + return callStoreServiceByUserId(userId); + + } catch (Exception e) { + log.error("매장 정보 조회 실패, Mock 데이터 반환: userId={}, error={}", userId, e.getMessage()); + return createMockStoreData(userId); + } + } + + + public List getMenusByStoreId(Long storeId) { + log.info("매장 메뉴 조회 시작: storeId={}", storeId); + + try { + return callMenuService(storeId); + } catch (Exception e) { + log.error("메뉴 조회 실패, Mock 데이터 반환: storeId={}", storeId, e); + return createMockMenuData(storeId); + } + } + + private StoreData callStoreServiceByUserId(String userId) { + + try { + StoreApiResponse response = webClient + .get() + .uri(storeServiceBaseUrl + "/api/store") + .header("Authorization", "Bearer " + getCurrentJwtToken()) // JWT 토큰 추가 + .retrieve() + .bodyToMono(StoreApiResponse.class) + .timeout(Duration.ofMillis(timeout)) + .block(); + + log.info("response : {}", response.getData().getStoreName()); + log.info("response : {}", response.getData().getStoreId()); + + if (response != null && response.getData() != null) { + StoreApiResponse.StoreInfo storeInfo = response.getData(); + return StoreData.builder() + .storeId(storeInfo.getStoreId()) + .storeName(storeInfo.getStoreName()) + .businessType(storeInfo.getBusinessType()) + .location(storeInfo.getAddress()) + .description(storeInfo.getDescription()) + .seatCount(storeInfo.getSeatCount()) + .build(); + } + } catch (WebClientResponseException e) { + if (e.getStatusCode().value() == 404) { + throw new BusinessException(ErrorCode.STORE_NOT_FOUND); + } + log.error("매장 서비스 호출 실패: {}", e.getMessage()); + } + + return createMockStoreData(userId); + } + + private String getCurrentJwtToken() { + try { + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + + if (attributes == null) { + log.warn("RequestAttributes를 찾을 수 없음 - HTTP 요청 컨텍스트 없음"); + return null; + } + + HttpServletRequest request = attributes.getRequest(); + String bearerToken = request.getHeader(AUTHORIZATION_HEADER); + + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) { + String token = bearerToken.substring(BEARER_PREFIX.length()); + log.debug("JWT 토큰 추출 성공: {}...", token.substring(0, Math.min(10, token.length()))); + return token; + } else { + log.warn("Authorization 헤더에서 Bearer 토큰을 찾을 수 없음: {}", bearerToken); + return null; + } + + } catch (Exception e) { + log.error("JWT 토큰 추출 중 오류 발생: {}", e.getMessage()); + return null; + } + } + + private List callMenuService(Long storeId) { + try { + MenuApiResponse response = webClient + .get() + .uri(storeServiceBaseUrl + "/api/menu/store/" + storeId) + .retrieve() + .bodyToMono(MenuApiResponse.class) + .timeout(Duration.ofMillis(timeout)) + .block(); + + if (response != null && response.getData() != null && !response.getData().isEmpty()) { + List menuDataList = response.getData().stream() + .map(this::toMenuData) + .collect(Collectors.toList()); + + log.info("매장 메뉴 조회 성공: storeId={}, menuCount={}", storeId, menuDataList.size()); + return menuDataList; + } + } catch (WebClientResponseException e) { + if (e.getStatusCode().value() == 404) { + log.warn("매장의 메뉴 정보가 없습니다: storeId={}", storeId); + return Collections.emptyList(); + } + log.error("메뉴 서비스 호출 실패: storeId={}, error={}", storeId, e.getMessage()); + } catch (WebClientException e) { + log.error("메뉴 서비스 연결 실패: storeId={}, error={}", storeId, e.getMessage()); + } + + return createMockMenuData(storeId); + } + + /** + * MenuResponse를 MenuData로 변환 + */ + private MenuData toMenuData(MenuApiResponse.MenuInfo menuInfo) { + return MenuData.builder() + .menuId(menuInfo.getMenuId()) + .menuName(menuInfo.getMenuName()) + .category(menuInfo.getCategory()) + .price(menuInfo.getPrice()) + .description(menuInfo.getDescription()) + .build(); + } + + private StoreData createMockStoreData(String userId) { + return StoreData.builder() + .storeName("테스트 카페 " + userId) + .businessType("카페") + .location("서울시 강남구") + .build(); + } + + private List createMockMenuData(Long storeId) { + log.info("Mock 메뉴 데이터 생성: storeId={}", storeId); + + return List.of( + MenuData.builder() + .menuId(1L) + .menuName("아메리카노") + .category("음료") + .price(4000) + .description("깊고 진한 맛의 아메리카노") + .build(), + MenuData.builder() + .menuId(2L) + .menuName("카페라떼") + .category("음료") + .price(4500) + .description("부드러운 우유 거품이 올라간 카페라떼") + .build(), + MenuData.builder() + .menuId(3L) + .menuName("치즈케이크") + .category("디저트") + .price(6000) + .description("진한 치즈 맛의 수제 케이크") + + .build() + ); + } + + @Getter + 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; } + + @Getter + static class StoreInfo { + private Long storeId; + private String storeName; + private String businessType; + private String address; + private String description; + private Integer seatCount; + } + } + + /** + * Menu API 응답 DTO (새로 추가) + */ + private static class MenuApiResponse { + private List data; + private String message; + private boolean success; + + public List getData() { return data; } + public void setData(List data) { this.data = data; } + public String getMessage() { return message; } + public void setMessage(String message) { this.message = message; } + public boolean isSuccess() { return success; } + public void setSuccess(boolean success) { this.success = success; } + + public static class MenuInfo { + private Long menuId; + private String menuName; + private String category; + private Integer price; + private String description; + private String image; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public Long getMenuId() { return menuId; } + public void setMenuId(Long menuId) { this.menuId = menuId; } + public String getMenuName() { return menuName; } + public void setMenuName(String menuName) { this.menuName = menuName; } + public String getCategory() { return category; } + public void setCategory(String category) { this.category = category; } + public Integer getPrice() { return price; } + public void setPrice(Integer price) { this.price = price; } + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + public String getImage() { return image; } + public void setImage(String image) { this.image = image; } + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + public LocalDateTime getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } + } + } +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipEntity.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipEntity.java new file mode 100644 index 0000000..bcbda7e --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipEntity.java @@ -0,0 +1,81 @@ +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.annotation.LastModifiedDate; +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) + @Column(name = "tip_id", nullable = false) + private Long id; + + @Column(name = "user_id", nullable = false, length = 50) + private String userId; + + @Column(name = "store_id", nullable = false) + private Long storeId; + + @Column(name = "tip_summary") + private String tipSummary; + + @Lob + @Column(name = "tip_content", nullable = false, columnDefinition = "TEXT") + private String tipContent; + + @Column(name = "ai_model") + private String aiModel; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + public static MarketingTipEntity fromDomain(MarketingTip marketingTip, String userId) { + return MarketingTipEntity.builder() + .id(marketingTip.getId() != null ? marketingTip.getId().getValue() : null) + .userId(userId) + .storeId(marketingTip.getStoreId()) + .tipContent(marketingTip.getTipContent()) + .tipSummary(marketingTip.getTipSummary()) + .createdAt(marketingTip.getCreatedAt()) + .updatedAt(marketingTip.getUpdatedAt()) + .build(); + } + + + public MarketingTip toDomain(StoreData storeData) { + return MarketingTip.builder() + .id(this.id != null ? TipId.of(this.id) : null) + .storeId(this.storeId) + .tipSummary(this.tipSummary) + .tipContent(this.tipContent) + .createdAt(this.createdAt) + .updatedAt(this.updatedAt) + .build(); + } +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipJpaRepository.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipJpaRepository.java new file mode 100644 index 0000000..c047eaa --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipJpaRepository.java @@ -0,0 +1,40 @@ +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; + +import java.util.Optional; + +/** + * 마케팅 팁 JPA 레포지토리 + */ +@Repository +public interface MarketingTipJpaRepository extends JpaRepository { + + /** + * 매장별 마케팅 팁 조회 (기존 - storeId 기반) + */ + @Query("SELECT m FROM MarketingTipEntity m WHERE m.storeId = :storeId ORDER BY m.createdAt DESC") + Page findByStoreIdOrderByCreatedAtDesc(@Param("storeId") Long storeId, Pageable pageable); + + /** + * 사용자별 마케팅 팁 조회 (새로 추가 - userId 기반) + */ + @Query("SELECT m FROM MarketingTipEntity m WHERE m.userId = :userId ORDER BY m.createdAt DESC") + Page findByUserIdOrderByCreatedAtDesc(@Param("userId") String userId, Pageable pageable); + + /** + * 사용자의 가장 최근 마케팅 팁 조회 + */ + @Query("SELECT m FROM MarketingTipEntity m WHERE m.userId = :userId ORDER BY m.createdAt DESC LIMIT 1") + Optional findTopByUserIdOrderByCreatedAtDesc(@Param("userId") String userId); + + /** + * 특정 팁이 해당 사용자의 것인지 확인 + */ + boolean existsByIdAndUserId(Long id, String userId); +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipRepositoryImpl.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipRepositoryImpl.java new file mode 100644 index 0000000..1ad2b9b --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/persistence/MarketingTipRepositoryImpl.java @@ -0,0 +1,88 @@ +package com.won.smarketing.recommend.infrastructure.persistence; + +import com.won.smarketing.recommend.domain.model.MarketingTip; +import com.won.smarketing.recommend.domain.model.StoreWithMenuData; +import com.won.smarketing.recommend.domain.repository.MarketingTipRepository; +import com.won.smarketing.recommend.domain.service.StoreDataProvider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Slf4j +@Repository +@RequiredArgsConstructor +public class MarketingTipRepositoryImpl implements MarketingTipRepository { + + private final MarketingTipJpaRepository jpaRepository; + private final StoreDataProvider storeDataProvider; + + @Override + public MarketingTip save(MarketingTip marketingTip) { + String userId = getCurrentUserId(); + MarketingTipEntity entity = MarketingTipEntity.fromDomain(marketingTip, userId); + MarketingTipEntity savedEntity = jpaRepository.save(entity); + + // Store 정보는 다시 조회해서 Domain에 설정 + StoreWithMenuData storeWithMenuData = storeDataProvider.getStoreWithMenuData(userId); + return savedEntity.toDomain(storeWithMenuData.getStoreData()); + } + + @Override + public Optional findById(Long tipId) { + return jpaRepository.findById(tipId) + .map(entity -> { + // Store 정보를 API로 조회 + StoreWithMenuData storeWithMenuData = storeDataProvider.getStoreWithMenuData(entity.getUserId()); + return entity.toDomain(storeWithMenuData.getStoreData()); + }); + } + + @Override + public Page findByStoreIdOrderByCreatedAtDesc(Long storeId, Pageable pageable) { + // 기존 메서드는 호환성을 위해 유지하지만, 내부적으로는 userId로 조회 + String userId = getCurrentUserId(); + return findByUserIdOrderByCreatedAtDesc(userId, pageable); + } + + /** + * 사용자별 마케팅 팁 조회 (새로 추가) + */ + public Page findByUserIdOrderByCreatedAtDesc(String userId, Pageable pageable) { + Page entities = jpaRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable); + + // Store 정보는 한 번만 조회 (같은 userId이므로) + StoreWithMenuData storeWithMenuData = storeDataProvider.getStoreWithMenuData(userId); + + return entities.map(entity -> entity.toDomain(storeWithMenuData.getStoreData())); + } + + /** + * 사용자의 가장 최근 마케팅 팁 조회 + */ + public Optional findMostRecentByUserId(String userId) { + return jpaRepository.findTopByUserIdOrderByCreatedAtDesc(userId) + .map(entity -> { + StoreWithMenuData storeWithMenuData = storeDataProvider.getStoreWithMenuData(userId); + return entity.toDomain(storeWithMenuData.getStoreData()); + }); + } + + /** + * 특정 팁이 해당 사용자의 것인지 확인 + */ + public boolean isOwnedByUser(Long tipId, String userId) { + return jpaRepository.existsByIdAndUserId(tipId, userId); + } + + /** + * 현재 로그인된 사용자 ID 조회 + */ + private String getCurrentUserId() { + return SecurityContextHolder.getContext().getAuthentication().getName(); + } +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/HealthController.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/HealthController.java new file mode 100644 index 0000000..ad30482 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/HealthController.java @@ -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 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); \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/RecommendationController.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/RecommendationController.java new file mode 100644 index 0000000..83a66f7 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/controller/RecommendationController.java @@ -0,0 +1,41 @@ +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.MarketingTipResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; + +/** + * AI 마케팅 추천 컨트롤러 (단일 API) + */ +@Tag(name = "AI 추천", description = "AI 기반 마케팅 팁 추천 API") +@Slf4j +@RestController +@RequestMapping("/api/recommendations") +@RequiredArgsConstructor +public class RecommendationController { + + private final MarketingTipUseCase marketingTipUseCase; + + @Operation( + summary = "마케팅 팁 조회/생성", + description = "마케팅 팁 전체 내용 조회. 1시간 이내 생성된 팁이 있으면 기존 것 사용, 없으면 새로 생성" + ) + @PostMapping("/marketing-tips") + public ResponseEntity> provideMarketingTip() { + + log.info("마케팅 팁 제공 요청"); + + MarketingTipResponse response = marketingTipUseCase.provideMarketingTip(); + + log.info("마케팅 팁 제공 완료: tipId={}", response.getTipId()); + return ResponseEntity.ok(ApiResponse.success(response)); + } +} diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipResponse.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipResponse.java new file mode 100644 index 0000000..d60ce97 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/presentation/dto/MarketingTipResponse.java @@ -0,0 +1,57 @@ +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; + +/** + * 마케팅 팁 응답 DTO (요약 + 상세 통합) + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "마케팅 팁 응답") +public class MarketingTipResponse { + + @Schema(description = "팁 ID", example = "1") + private Long tipId; + + @Schema(description = "마케팅 팁 요약 (1줄)", example = "가을 시즌 특별 음료로 고객들의 관심을 끌어보세요!") + private String tipSummary; + + @Schema(description = "마케팅 팁 전체 내용", example = "가을이 다가오면서 고객들은 따뜻하고 계절감 있는 음료를 찾게 됩니다...") + private String tipContent; + + @Schema(description = "매장 정보") + private StoreInfo storeInfo; + + @Schema(description = "생성 시간", example = "2025-06-13T14:30:00") + private LocalDateTime createdAt; + + @Schema(description = "수정 시간", example = "2025-06-13T14:30:00") + private LocalDateTime updatedAt; + + @Schema(description = "1시간 이내 생성 여부", example = "true") + private boolean isRecentlyCreated; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "매장 정보") + public static class StoreInfo { + @Schema(description = "매장명", example = "민코의 카페") + private String storeName; + + @Schema(description = "업종", example = "카페") + private String businessType; + + @Schema(description = "위치", example = "서울시 강남구 테헤란로 123") + private String location; + } +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/resources/application.yml b/smarketing-java/ai-recommend/src/main/resources/application.yml new file mode 100644 index 0000000..88d3902 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/resources/application.yml @@ -0,0 +1,52 @@ +server: + port: ${SERVER_PORT:8084} + servlet: + context-path: / + +spring: + application: + name: ai-recommend-service + datasource: + url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:AiRecommendationDB} + username: ${POSTGRES_USER:postgres} + password: ${POSTGRES_PASSWORD:postgres} + jpa: + hibernate: + ddl-auto: ${JPA_DDL_AUTO:create-drop} + 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:} + +external: + store-service: + base-url: ${STORE_SERVICE_URL:http://localhost:8082} + timeout: ${STORE_SERVICE_TIMEOUT:5000} + python-ai-service: + base-url: ${PYTHON_AI_SERVICE_URL:http://localhost:5001} + 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 + +logging: + level: + 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} \ No newline at end of file diff --git a/smarketing-java/build.gradle b/smarketing-java/build.gradle new file mode 100644 index 0000000..6c51f31 --- /dev/null +++ b/smarketing-java/build.gradle @@ -0,0 +1,55 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.2.0' + id 'io.spring.dependency-management' version '1.1.4' +} +// 루트 프로젝트에서는 bootJar 태스크 비활성화 +bootJar { + enabled = false +} + +allprojects { + group = 'com.won.smarketing' + version = '1.0.0' + + repositories { + mavenCentral() + } +} + +subprojects { + apply plugin: 'java' + apply plugin: 'org.springframework.boot' + apply plugin: 'io.spring.dependency-management' + + configurations { + compileOnly { + extendsFrom annotationProcessor + } + } + + dependencies { + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-webflux' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation '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' + implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' + implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + + // PostgreSQL (운영용) + runtimeOnly 'org.postgresql:postgresql:42.7.1' + + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + } + + tasks.named('test') { + useJUnitPlatform() + } +} \ No newline at end of file diff --git a/smarketing-java/common/build.gradle b/smarketing-java/common/build.gradle new file mode 100644 index 0000000..b46abbb --- /dev/null +++ b/smarketing-java/common/build.gradle @@ -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' +} \ No newline at end of file diff --git a/smarketing-java/common/src/main/java/com/won/smarketing/common/config/RedisConfig.java b/smarketing-java/common/src/main/java/com/won/smarketing/common/config/RedisConfig.java new file mode 100644 index 0000000..a0bc038 --- /dev/null +++ b/smarketing-java/common/src/main/java/com/won/smarketing/common/config/RedisConfig.java @@ -0,0 +1,69 @@ +package com.won.smarketing.common.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +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.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +/** + * Redis 설정 클래스 + * Redis 연결 및 템플릿 설정 + */ +@Configuration +public class RedisConfig { + + @Value("${spring.data.redis.host}") + private String redisHost; + + @Value("${spring.data.redis.port}") + private int redisPort; + + @Value("${spring.data.redis.password:}") + private String redisPassword; + + @Value("${spring.data.redis.ssl:true}") + private boolean useSsl; + + /** + * Redis 연결 팩토리 설정 + * + * @return Redis 연결 팩토리 + */ + @Bean + public RedisConnectionFactory redisConnectionFactory() { + 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; + } + + /** + * Redis 템플릿 설정 + * + * @return Redis 템플릿 + */ + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + return redisTemplate; + } +} diff --git a/smarketing-java/common/src/main/java/com/won/smarketing/common/config/SecurityConfig.java b/smarketing-java/common/src/main/java/com/won/smarketing/common/config/SecurityConfig.java new file mode 100644 index 0000000..5c61143 --- /dev/null +++ b/smarketing-java/common/src/main/java/com/won/smarketing/common/config/SecurityConfig.java @@ -0,0 +1,83 @@ +package com.won.smarketing.common.config; + +import com.won.smarketing.common.security.JwtAuthenticationFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.Arrays; + +/** + * Spring Security 설정 클래스 + * JWT 기반 인증 및 CORS 설정 + */ +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + /** + * Spring Security 필터 체인 설정 + * + * @param http HttpSecurity 객체 + * @return SecurityFilterChain + * @throws Exception 예외 + */ + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/auth/**", "/api/member/register", "/api/member/check-duplicate/**", + "/api/member/validate-password", "/swagger-ui/**", "/v3/api-docs/**", + "/swagger-resources/**", "/webjars/**").permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + /** + * 패스워드 인코더 빈 등록 + * + * @return BCryptPasswordEncoder + */ + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + /** + * CORS 설정 + * + * @return CorsConfigurationSource + */ + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOriginPatterns(Arrays.asList("*")); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(Arrays.asList("*")); + configuration.setAllowCredentials(true); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} diff --git a/smarketing-java/common/src/main/java/com/won/smarketing/common/config/SwaggerConfig.java b/smarketing-java/common/src/main/java/com/won/smarketing/common/config/SwaggerConfig.java new file mode 100644 index 0000000..fb21909 --- /dev/null +++ b/smarketing-java/common/src/main/java/com/won/smarketing/common/config/SwaggerConfig.java @@ -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); + } +} diff --git a/smarketing-java/common/src/main/java/com/won/smarketing/common/dto/ApiResponse.java b/smarketing-java/common/src/main/java/com/won/smarketing/common/dto/ApiResponse.java new file mode 100644 index 0000000..dbb123b --- /dev/null +++ b/smarketing-java/common/src/main/java/com/won/smarketing/common/dto/ApiResponse.java @@ -0,0 +1,77 @@ +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; + +/** + * 표준 API 응답 DTO + * 모든 API 응답에 사용되는 공통 형식 + * + * @param 응답 데이터 타입 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "API 응답") +public class ApiResponse { + + @Schema(description = "응답 상태 코드", example = "200") + private int status; + + @Schema(description = "응답 메시지", example = "요청이 성공적으로 처리되었습니다.") + private String message; + + @Schema(description = "응답 데이터") + private T data; + + /** + * 성공 응답 생성 (데이터 포함) + * + * @param data 응답 데이터 + * @param 데이터 타입 + * @return 성공 응답 + */ + public static ApiResponse success(T data) { + return ApiResponse.builder() + .status(200) + .message("요청이 성공적으로 처리되었습니다.") + .data(data) + .build(); + } + + /** + * 성공 응답 생성 (데이터 및 메시지 포함) + * + * @param data 응답 데이터 + * @param message 응답 메시지 + * @param 데이터 타입 + * @return 성공 응답 + */ + public static ApiResponse success(T data, String message) { + return ApiResponse.builder() + .status(200) + .message(message) + .data(data) + .build(); + } + + /** + * 오류 응답 생성 + * + * @param status 오류 상태 코드 + * @param message 오류 메시지 + * @param 데이터 타입 + * @return 오류 응답 + */ + public static ApiResponse error(int status, String message) { + return ApiResponse.builder() + .status(status) + .message(message) + .data(null) + .build(); + } +} diff --git a/smarketing-java/common/src/main/java/com/won/smarketing/common/dto/PageResponse.java b/smarketing-java/common/src/main/java/com/won/smarketing/common/dto/PageResponse.java new file mode 100644 index 0000000..ab77b3f --- /dev/null +++ b/smarketing-java/common/src/main/java/com/won/smarketing/common/dto/PageResponse.java @@ -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 응답 데이터 타입 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "페이징 응답") +public class PageResponse { + + @Schema(description = "페이지 컨텐츠", example = "[...]") + private List 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 데이터 타입 + * @return 페이징 응답 + */ + public static PageResponse of(List content, int pageNumber, int pageSize, long totalElements) { + int totalPages = (int) Math.ceil((double) totalElements / pageSize); + + return PageResponse.builder() + .content(content) + .pageNumber(pageNumber) + .pageSize(pageSize) + .totalElements(totalElements) + .totalPages(totalPages) + .first(pageNumber == 0) + .last(pageNumber >= totalPages - 1) + .build(); + } +} diff --git a/smarketing-java/common/src/main/java/com/won/smarketing/common/exception/BusinessException.java b/smarketing-java/common/src/main/java/com/won/smarketing/common/exception/BusinessException.java new file mode 100644 index 0000000..9a8b7d6 --- /dev/null +++ b/smarketing-java/common/src/main/java/com/won/smarketing/common/exception/BusinessException.java @@ -0,0 +1,34 @@ +package com.won.smarketing.common.exception; + +import lombok.Getter; + +/** + * 비즈니스 로직 예외 + * 애플리케이션 내 비즈니스 규칙 위반 시 발생하는 예외 + */ +@Getter +public class BusinessException extends RuntimeException { + + private final ErrorCode errorCode; + + /** + * 비즈니스 예외 생성자 + * + * @param errorCode 오류 코드 + */ + public BusinessException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + /** + * 비즈니스 예외 생성자 (추가 메시지 포함) + * + * @param errorCode 오류 코드 + * @param message 추가 메시지 + */ + public BusinessException(ErrorCode errorCode, String message) { + super(message); + this.errorCode = errorCode; + } +} diff --git a/smarketing-java/common/src/main/java/com/won/smarketing/common/exception/ErrorCode.java b/smarketing-java/common/src/main/java/com/won/smarketing/common/exception/ErrorCode.java new file mode 100644 index 0000000..be6bb93 --- /dev/null +++ b/smarketing-java/common/src/main/java/com/won/smarketing/common/exception/ErrorCode.java @@ -0,0 +1,58 @@ +package com.won.smarketing.common.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +/** + * 애플리케이션 오류 코드 정의 + * 각 오류 상황에 대한 코드, HTTP 상태, 메시지 정의 + */ +@Getter +@RequiredArgsConstructor +public enum ErrorCode { + + // 회원 관련 오류 + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "M001", "회원을 찾을 수 없습니다."), + DUPLICATE_MEMBER_ID(HttpStatus.BAD_REQUEST, "M002", "이미 사용 중인 사용자 ID입니다."), + DUPLICATE_EMAIL(HttpStatus.BAD_REQUEST, "M003", "이미 사용 중인 이메일입니다."), + DUPLICATE_BUSINESS_NUMBER(HttpStatus.BAD_REQUEST, "M004", "이미 등록된 사업자 번호입니다."), + INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "M005", "잘못된 패스워드입니다."), + INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "M006", "유효하지 않은 토큰입니다."), + TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "M007", "만료된 토큰입니다."), + + // 매장 관련 오류 + STORE_NOT_FOUND(HttpStatus.NOT_FOUND, "S001", "매장을 찾을 수 없습니다."), + STORE_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "S002", "이미 등록된 매장이 있습니다."), + MENU_NOT_FOUND(HttpStatus.NOT_FOUND, "S003", "메뉴를 찾을 수 없습니다."), + + // 마케팅 콘텐츠 관련 오류 + CONTENT_NOT_FOUND(HttpStatus.NOT_FOUND, "C001", "콘텐츠를 찾을 수 없습니다."), + CONTENT_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "C002", "콘텐츠 생성에 실패했습니다."), + AI_SERVICE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "C003", "AI 서비스를 사용할 수 없습니다."), + + // AI 추천 관련 오류 + RECOMMENDATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "R001", "추천 생성에 실패했습니다."), + EXTERNAL_API_ERROR(HttpStatus.SERVICE_UNAVAILABLE, "R002", "외부 API 호출에 실패했습니다."), + + FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "F001", "파일을 찾을 수 없습니다."), + FILE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "F002", "파일 업로드에 실패했습니다."), + FILE_SIZE_EXCEEDED(HttpStatus.NOT_FOUND, "F003", "파일 크기가 제한을 초과했습니다."), + INVALID_FILE_EXTENSION(HttpStatus.NOT_FOUND, "F004", "지원하지 않는 파일 확장자입니다."), + INVALID_FILE_TYPE(HttpStatus.NOT_FOUND, "F005", "지원하지 않는 파일 형식입니다."), + INVALID_FILE_NAME(HttpStatus.NOT_FOUND, "F006", "잘못된 파일명입니다."), + INVALID_FILE_URL(HttpStatus.NOT_FOUND, "F007", "잘못된 파일 URL입니다."), + STORAGE_CONTAINER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "F008", "스토리지 컨테이너 오류가 발생했습니다."), + + // 공통 오류 + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "G001", "서버 내부 오류가 발생했습니다."), + INVALID_INPUT_VALUE(HttpStatus.BAD_REQUEST, "G002", "잘못된 입력값입니다."), + INVALID_TYPE_VALUE(HttpStatus.BAD_REQUEST, "G003", "잘못된 타입의 값입니다."), + MISSING_REQUEST_PARAMETER(HttpStatus.BAD_REQUEST, "G004", "필수 요청 파라미터가 누락되었습니다."), + ACCESS_DENIED(HttpStatus.FORBIDDEN, "G005", "접근이 거부되었습니다."), + METHOD_NOT_ALLOWED(HttpStatus.METHOD_NOT_ALLOWED, "G006", "허용되지 않은 HTTP 메서드입니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; +} diff --git a/smarketing-java/common/src/main/java/com/won/smarketing/common/exception/GlobalExceptionHandler.java b/smarketing-java/common/src/main/java/com/won/smarketing/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..d2da2b8 --- /dev/null +++ b/smarketing-java/common/src/main/java/com/won/smarketing/common/exception/GlobalExceptionHandler.java @@ -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> 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>> handleValidationException( + MethodArgumentNotValidException ex) { + log.warn("Validation exception occurred: {}", ex.getMessage()); + + Map 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.>builder() + .status(400) + .message("입력값 검증에 실패했습니다.") + .data(errors) + .build()); + } + + /** + * 일반적인 예외 처리 + * + * @param ex 예외 + * @return 오류 응답 + */ + @ExceptionHandler(Exception.class) + public ResponseEntity> handleException(Exception ex) { + log.error("Unexpected exception occurred", ex); + + return ResponseEntity.internalServerError() + .body(ApiResponse.error(500, "서버 내부 오류가 발생했습니다.")); + } +} diff --git a/smarketing-java/common/src/main/java/com/won/smarketing/common/security/JwtAuthenticationFilter.java b/smarketing-java/common/src/main/java/com/won/smarketing/common/security/JwtAuthenticationFilter.java new file mode 100644 index 0000000..16381bd --- /dev/null +++ b/smarketing-java/common/src/main/java/com/won/smarketing/common/security/JwtAuthenticationFilter.java @@ -0,0 +1,82 @@ +package com.won.smarketing.common.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Collections; + +/** + * JWT 인증 필터 + * HTTP 요청에서 JWT 토큰을 추출하고 인증 처리 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + private static final String AUTHORIZATION_HEADER = "Authorization"; + private static final String BEARER_PREFIX = "Bearer "; + + /** + * JWT 토큰 기반 인증 필터링 + * + * @param request HTTP 요청 + * @param response HTTP 응답 + * @param filterChain 필터 체인 + * @throws ServletException 서블릿 예외 + * @throws IOException IO 예외 + */ + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + + try { + String jwt = getJwtFromRequest(request); + + if (StringUtils.hasText(jwt) && jwtTokenProvider.validateToken(jwt)) { + String userId = jwtTokenProvider.getUserIdFromToken(jwt); + + // 사용자 인증 정보 설정 + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken(userId, null, Collections.emptyList()); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + + 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); + } + + /** + * HTTP 요청에서 JWT 토큰 추출 + * + * @param request HTTP 요청 + * @return JWT 토큰 (Bearer 접두사 제거된) + */ + private String getJwtFromRequest(HttpServletRequest request) { + String bearerToken = request.getHeader(AUTHORIZATION_HEADER); + + if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(BEARER_PREFIX)) { + return bearerToken.substring(BEARER_PREFIX.length()); + } + + return null; + } +} diff --git a/smarketing-java/common/src/main/java/com/won/smarketing/common/security/JwtTokenProvider.java b/smarketing-java/common/src/main/java/com/won/smarketing/common/security/JwtTokenProvider.java new file mode 100644 index 0000000..d88bc8e --- /dev/null +++ b/smarketing-java/common/src/main/java/com/won/smarketing/common/security/JwtTokenProvider.java @@ -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; + } + +} \ No newline at end of file diff --git a/smarketing-java/deployment/Jenkinsfile b/smarketing-java/deployment/Jenkinsfile new file mode 100644 index 0000000..b281bd7 --- /dev/null +++ b/smarketing-java/deployment/Jenkinsfile @@ -0,0 +1,217 @@ +def PIPELINE_ID = "${env.BUILD_NUMBER}" + +def getImageTag() { + def dateFormat = new java.text.SimpleDateFormat('yyyyMMddHHmmss') + def currentDate = new Date() + return dateFormat.format(currentDate) +} + +podTemplate( + label: "${PIPELINE_ID}", + serviceAccount: 'jenkins', + containers: [ + containerTemplate(name: 'gradle', image: 'gradle:jdk17', ttyEnabled: true, command: 'cat'), + containerTemplate(name: 'docker', image: 'docker:20.10.16-dind', ttyEnabled: true, privileged: true), + containerTemplate(name: 'azure-cli', image: 'hiondal/azure-kubectl:latest', command: 'cat', ttyEnabled: true), + containerTemplate(name: 'envsubst', image: "hiondal/envsubst", command: 'sleep', args: '1h') + ], + volumes: [ + emptyDirVolume(mountPath: '/home/gradle/.gradle', memory: false), + emptyDirVolume(mountPath: '/root/.azure', memory: false), + emptyDirVolume(mountPath: '/var/run', memory: false) + ] +) { + node(PIPELINE_ID) { + def props + def imageTag = getImageTag() + def manifest = "deploy.yaml" + def namespace + def services = ['member', 'store', 'marketing-content', 'ai-recommend'] + + stage("Get Source") { + checkout scm + + // smarketing-java 하위에 있는 설정 파일 읽기 + props = readProperties file: "smarketing-java/deployment/deploy_env_vars" + namespace = "${props.namespace}" + + echo "=== Build Information ===" + echo "Services: ${services}" + echo "Namespace: ${namespace}" + echo "Image Tag: ${imageTag}" + } + + stage("Setup AKS") { + container('azure-cli') { + withCredentials([azureServicePrincipal('azure-credentials')]) { + sh """ + echo "=== Azure 로그인 ===" + az login --service-principal -u \$AZURE_CLIENT_ID -p \$AZURE_CLIENT_SECRET -t \$AZURE_TENANT_ID + az account set --subscription 2513dd36-7978-48e3-9a7c-b221d4874f66 + + echo "=== AKS 인증정보 가져오기 ===" + az aks get-credentials --resource-group rg-digitalgarage-01 --name aks-digitalgarage-01 --overwrite-existing + + echo "=== 네임스페이스 생성 ===" + kubectl create namespace ${namespace} --dry-run=client -o yaml | kubectl apply -f - + + echo "=== Image Pull Secret 생성 ===" + kubectl create secret docker-registry acr-secret \\ + --docker-server=${props.registry} \\ + --docker-username=acrdigitalgarage02 \\ + --docker-password=\$(az acr credential show --name acrdigitalgarage02 --query passwords[0].value -o tsv) \\ + --namespace=${namespace} \\ + --dry-run=client -o yaml | kubectl apply -f - + + echo "=== 클러스터 상태 확인 ===" + kubectl get nodes + kubectl get ns ${namespace} + """ + } + } + } + + stage('Build Applications') { + container('gradle') { + sh """ + echo "=== smarketing-java 디렉토리로 이동 ===" + cd smarketing-java + + echo "=== gradlew 권한 설정 ===" + chmod +x gradlew + + echo "=== 전체 서비스 빌드 ===" + ./gradlew :member:clean :member:build -x test + ./gradlew :store:clean :store:build -x test + ./gradlew :marketing-content:clean :marketing-content:build -x test + ./gradlew :ai-recommend:clean :ai-recommend:build -x test + + echo "=== 빌드 결과 확인 ===" + find . -name "*.jar" -path "*/build/libs/*" | grep -v 'plain.jar' + """ + } + } + + stage('Build & Push Images') { + container('docker') { + sh """ + echo "=== Docker 데몬 시작 대기 ===" + timeout 30 sh -c 'until docker info; do sleep 1; done' + """ + + // 🔧 ACR Credential을 Jenkins에서 직접 사용 + withCredentials([usernamePassword( + credentialsId: 'acr-credentials', + usernameVariable: 'ACR_USERNAME', + passwordVariable: 'ACR_PASSWORD' + )]) { + sh """ + echo "=== Docker로 ACR 로그인 ===" + echo "\$ACR_PASSWORD" | docker login ${props.registry} --username \$ACR_USERNAME --password-stdin + """ + + services.each { service -> + script { + def buildDir = "smarketing-java/${service}" + def fullImageName = "${props.registry}/${props.image_org}/${service}:${imageTag}" + + echo "Building image for ${service}: ${fullImageName}" + + // 실제 JAR 파일명 동적 탐지 + def actualJarFile = sh( + script: """ + cd ${buildDir}/build/libs + ls *.jar | grep -v 'plain.jar' | head -1 + """, + returnStdout: true + ).trim() + + if (!actualJarFile) { + error "${service} JAR 파일을 찾을 수 없습니다" + } + + echo "발견된 JAR 파일: ${actualJarFile}" + + sh """ + echo "=== ${service} 이미지 빌드 ===" + docker build \\ + --build-arg BUILD_LIB_DIR="${buildDir}/build/libs" \\ + --build-arg ARTIFACTORY_FILE="${actualJarFile}" \\ + -f smarketing-java/deployment/container/Dockerfile \\ + -t ${fullImageName} . + + echo "=== ${service} 이미지 푸시 ===" + docker push ${fullImageName} + + echo "Successfully built and pushed: ${fullImageName}" + """ + } + } + } + } + } + + stage('Generate & Apply Manifest') { + container('envsubst') { + sh """ + echo "=== 환경변수 설정 ===" + export namespace=${namespace} + export allowed_origins=${props.allowed_origins} + export jwt_secret_key=${props.jwt_secret_key} + export postgres_user=${props.postgres_user} + export postgres_password=${props.postgres_password} + export replicas=${props.replicas} + # 리소스 요구사항 조정 (작게) + export resources_requests_cpu=100m + export resources_requests_memory=128Mi + export resources_limits_cpu=500m + export resources_limits_memory=512Mi + + # 이미지 경로 환경변수 설정 + export member_image_path=${props.registry}/${props.image_org}/member:${imageTag} + export store_image_path=${props.registry}/${props.image_org}/store:${imageTag} + export marketing_content_image_path=${props.registry}/${props.image_org}/marketing-content:${imageTag} + export ai_recommend_image_path=${props.registry}/${props.image_org}/ai-recommend:${imageTag} + + echo "=== Manifest 생성 ===" + envsubst < smarketing-java/deployment/${manifest}.template > smarketing-java/deployment/${manifest} + + echo "=== Generated Manifest File ===" + cat smarketing-java/deployment/${manifest} + echo "===============================" + """ + } + + container('azure-cli') { + sh """ + echo "=== PostgreSQL 서비스 확인 ===" + kubectl get svc -n ${namespace} | grep postgresql || echo "PostgreSQL 서비스가 없습니다. 먼저 설치해주세요." + + echo "=== Manifest 적용 ===" + kubectl apply -f smarketing-java/deployment/${manifest} + + echo "=== 배포 상태 확인 (60초 대기) ===" + kubectl -n ${namespace} get deployments + kubectl -n ${namespace} get pods + + echo "=== 각 서비스 배포 대기 (60초 timeout) ===" + timeout 60 kubectl -n ${namespace} wait --for=condition=available deployment/member --timeout=60s || echo "member deployment 대기 타임아웃" + timeout 60 kubectl -n ${namespace} wait --for=condition=available deployment/store --timeout=60s || echo "store deployment 대기 타임아웃" + timeout 60 kubectl -n ${namespace} wait --for=condition=available deployment/marketing-content --timeout=60s || echo "marketing-content deployment 대기 타임아웃" + timeout 60 kubectl -n ${namespace} wait --for=condition=available deployment/ai-recommend --timeout=60s || echo "ai-recommend deployment 대기 타임아웃" + + echo "=== 최종 상태 ===" + kubectl -n ${namespace} get all + + echo "=== 실패한 Pod 상세 정보 ===" + for pod in \$(kubectl -n ${namespace} get pods --field-selector=status.phase!=Running -o name 2>/dev/null || true); do + if [ ! -z "\$pod" ]; then + echo "=== 실패한 Pod: \$pod ===" + kubectl -n ${namespace} describe \$pod | tail -20 + fi + done + """ + } + } + } +} diff --git a/smarketing-java/deployment/container/Dockerfile b/smarketing-java/deployment/container/Dockerfile new file mode 100644 index 0000000..be0f578 --- /dev/null +++ b/smarketing-java/deployment/container/Dockerfile @@ -0,0 +1,44 @@ +# Build stage +FROM eclipse-temurin:17-jre AS builder +ARG BUILD_LIB_DIR +ARG ARTIFACTORY_FILE +WORKDIR /app +COPY ${BUILD_LIB_DIR}/${ARTIFACTORY_FILE} app.jar + +# Run stage +FROM eclipse-temurin:17-jre + +# Install necessary packages +RUN apt-get update && apt-get install -y \ + curl \ + netcat-traditional \ + && rm -rf /var/lib/apt/lists/* + +ENV USERNAME k8s +ENV ARTIFACTORY_HOME /home/${USERNAME} +ENV JAVA_OPTS="" + +# Add a non-root user +RUN groupadd -r ${USERNAME} && useradd -r -g ${USERNAME} ${USERNAME} && \ + mkdir -p ${ARTIFACTORY_HOME} && \ + chown ${USERNAME}:${USERNAME} ${ARTIFACTORY_HOME} + +WORKDIR ${ARTIFACTORY_HOME} + +# Copy JAR from builder stage +COPY --from=builder /app/app.jar app.jar +RUN chown ${USERNAME}:${USERNAME} app.jar + +# Switch to non-root user +USER ${USERNAME} + +# Expose port +EXPOSE 8080 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=60s --retries=3 \ + CMD curl -f http://localhost:8080/actuator/health || exit 1 + +# Run the application +ENTRYPOINT ["sh", "-c"] +CMD ["java ${JAVA_OPTS} -jar app.jar"] diff --git a/smarketing-java/deployment/deploy.yaml.template b/smarketing-java/deployment/deploy.yaml.template new file mode 100644 index 0000000..4b88867 --- /dev/null +++ b/smarketing-java/deployment/deploy.yaml.template @@ -0,0 +1,475 @@ +# ConfigMap +apiVersion: v1 +kind: ConfigMap +metadata: + name: common-config + namespace: ${namespace} +data: + ALLOWED_ORIGINS: ${allowed_origins} + JPA_DDL_AUTO: update + JPA_SHOW_SQL: 'true' + +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: member-config + namespace: ${namespace} +data: + POSTGRES_DB: member + POSTGRES_HOST: member-postgresql + POSTGRES_PORT: '5432' + SERVER_PORT: '8081' + +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: store-config + namespace: ${namespace} +data: + POSTGRES_DB: store + POSTGRES_HOST: store-postgresql + POSTGRES_PORT: '5432' + SERVER_PORT: '8082' + +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: marketing-content-config + namespace: ${namespace} +data: + POSTGRES_DB: marketing_content + POSTGRES_HOST: marketing-content-postgresql + POSTGRES_PORT: '5432' + SERVER_PORT: '8083' + +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: ai-recommend-config + namespace: ${namespace} +data: + POSTGRES_DB: ai_recommend + POSTGRES_HOST: ai-recommend-postgresql + POSTGRES_PORT: '5432' + SERVER_PORT: '8084' + +--- +# Secrets +apiVersion: v1 +kind: Secret +metadata: + name: common-secret + namespace: ${namespace} +stringData: + JWT_SECRET_KEY: ${jwt_secret_key} +type: Opaque + +--- +apiVersion: v1 +kind: Secret +metadata: + name: member-secret + namespace: ${namespace} +stringData: + JWT_ACCESS_TOKEN_VALIDITY: '3600000' + JWT_REFRESH_TOKEN_VALIDITY: '86400000' + POSTGRES_PASSWORD: ${postgres_password} + POSTGRES_USER: ${postgres_user} +type: Opaque + +--- +apiVersion: v1 +kind: Secret +metadata: + name: store-secret + namespace: ${namespace} +stringData: + POSTGRES_PASSWORD: ${postgres_password} + POSTGRES_USER: ${postgres_user} +type: Opaque + +--- +apiVersion: v1 +kind: Secret +metadata: + name: marketing-content-secret + namespace: ${namespace} +stringData: + POSTGRES_PASSWORD: ${postgres_password} + POSTGRES_USER: ${postgres_user} +type: Opaque + +--- +apiVersion: v1 +kind: Secret +metadata: + name: ai-recommend-secret + namespace: ${namespace} +stringData: + POSTGRES_PASSWORD: ${postgres_password} + POSTGRES_USER: ${postgres_user} +type: Opaque + +--- +# Deployments +apiVersion: apps/v1 +kind: Deployment +metadata: + name: member + namespace: ${namespace} + labels: + app: member +spec: + replicas: ${replicas} + selector: + matchLabels: + app: member + template: + metadata: + labels: + app: member + spec: + imagePullSecrets: + - name: acr-secret + containers: + - name: member + image: ${member_image_path} + imagePullPolicy: Always + ports: + - containerPort: 8081 + resources: + requests: + cpu: ${resources_requests_cpu} + memory: ${resources_requests_memory} + limits: + cpu: ${resources_limits_cpu} + memory: ${resources_limits_memory} + envFrom: + - configMapRef: + name: common-config + - configMapRef: + name: member-config + - secretRef: + name: common-secret + - secretRef: + name: member-secret + startupProbe: + exec: + command: + - /bin/sh + - -c + - "nc -z member-postgresql 5432" + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 10 + livenessProbe: + httpGet: + path: /actuator/health + port: 8081 + initialDelaySeconds: 60 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /actuator/health + port: 8081 + initialDelaySeconds: 30 + periodSeconds: 5 + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: store + namespace: ${namespace} + labels: + app: store +spec: + replicas: ${replicas} + selector: + matchLabels: + app: store + template: + metadata: + labels: + app: store + spec: + imagePullSecrets: + - name: acr-secret + containers: + - name: store + image: ${store_image_path} + imagePullPolicy: Always + ports: + - containerPort: 8082 + resources: + requests: + cpu: ${resources_requests_cpu} + memory: ${resources_requests_memory} + limits: + cpu: ${resources_limits_cpu} + memory: ${resources_limits_memory} + envFrom: + - configMapRef: + name: common-config + - configMapRef: + name: store-config + - secretRef: + name: common-secret + - secretRef: + name: store-secret + startupProbe: + exec: + command: + - /bin/sh + - -c + - "nc -z store-postgresql 5432" + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 10 + livenessProbe: + httpGet: + path: /actuator/health + port: 8082 + initialDelaySeconds: 60 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /actuator/health + port: 8082 + initialDelaySeconds: 30 + periodSeconds: 5 + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: marketing-content + namespace: ${namespace} + labels: + app: marketing-content +spec: + replicas: ${replicas} + selector: + matchLabels: + app: marketing-content + template: + metadata: + labels: + app: marketing-content + spec: + imagePullSecrets: + - name: acr-secret + containers: + - name: marketing-content + image: ${marketing_content_image_path} + imagePullPolicy: Always + ports: + - containerPort: 8083 + resources: + requests: + cpu: ${resources_requests_cpu} + memory: ${resources_requests_memory} + limits: + cpu: ${resources_limits_cpu} + memory: ${resources_limits_memory} + envFrom: + - configMapRef: + name: common-config + - configMapRef: + name: marketing-content-config + - secretRef: + name: common-secret + - secretRef: + name: marketing-content-secret + startupProbe: + exec: + command: + - /bin/sh + - -c + - "nc -z marketing-content-postgresql 5432" + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 10 + livenessProbe: + httpGet: + path: /actuator/health + port: 8083 + initialDelaySeconds: 60 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /actuator/health + port: 8083 + initialDelaySeconds: 30 + periodSeconds: 5 + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ai-recommend + namespace: ${namespace} + labels: + app: ai-recommend +spec: + replicas: ${replicas} + selector: + matchLabels: + app: ai-recommend + template: + metadata: + labels: + app: ai-recommend + spec: + imagePullSecrets: + - name: acr-secret + containers: + - name: ai-recommend + image: ${ai_recommend_image_path} + imagePullPolicy: Always + ports: + - containerPort: 8084 + resources: + requests: + cpu: ${resources_requests_cpu} + memory: ${resources_requests_memory} + limits: + cpu: ${resources_limits_cpu} + memory: ${resources_limits_memory} + envFrom: + - configMapRef: + name: common-config + - configMapRef: + name: ai-recommend-config + - secretRef: + name: common-secret + - secretRef: + name: ai-recommend-secret + startupProbe: + exec: + command: + - /bin/sh + - -c + - "nc -z ai-recommend-postgresql 5432" + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 10 + livenessProbe: + httpGet: + path: /actuator/health + port: 8084 + initialDelaySeconds: 60 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /actuator/health + port: 8084 + initialDelaySeconds: 30 + periodSeconds: 5 + +--- +# Services +apiVersion: v1 +kind: Service +metadata: + name: member + namespace: ${namespace} +spec: + selector: + app: member + ports: + - port: 80 + targetPort: 8081 + type: ClusterIP + +--- +apiVersion: v1 +kind: Service +metadata: + name: store + namespace: ${namespace} +spec: + selector: + app: store + ports: + - port: 80 + targetPort: 8082 + type: ClusterIP + +--- +apiVersion: v1 +kind: Service +metadata: + name: marketing-content + namespace: ${namespace} +spec: + selector: + app: marketing-content + ports: + - port: 80 + targetPort: 8083 + type: ClusterIP + +--- +apiVersion: v1 +kind: Service +metadata: + name: ai-recommend + namespace: ${namespace} +spec: + selector: + app: ai-recommend + ports: + - port: 80 + targetPort: 8084 + type: ClusterIP + +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: smarketing-backend + namespace: ${namespace} + annotations: + kubernetes.io/ingress.class: nginx +spec: + ingressClassName: nginx + rules: + - http: + paths: + - path: /api/auth + pathType: Prefix + backend: + service: + name: member + port: + number: 80 + - path: /api/store + pathType: Prefix + backend: + service: + name: store + port: + number: 80 + - path: /api/content + pathType: Prefix + backend: + service: + name: marketing-content + port: + number: 80 + - path: /api/recommend + pathType: Prefix + backend: + service: + name: ai-recommend + port: + number: 80 diff --git a/smarketing-java/deployment/deploy_env_vars b/smarketing-java/deployment/deploy_env_vars new file mode 100644 index 0000000..db95eda --- /dev/null +++ b/smarketing-java/deployment/deploy_env_vars @@ -0,0 +1,23 @@ +# Team Settings +teamid=kros235 +root_project=smarketing-backend +namespace=smarketing + +# Container Registry Settings +registry=acrdigitalgarage02.azurecr.io +image_org=smarketing + +# Application Settings +replicas=1 +allowed_origins=http://20.249.171.38 + +# Security Settings +jwt_secret_key=8O2HQ13etL2BWZvYOiWsJ5uWFoLi6NBUG8divYVoCgtHVvlk3dqRksMl16toztDUeBTSIuOOPvHIrYq11G2BwQ +postgres_user=admin +postgres_password=Hi5Jessica! + +# Resource Settings (리소스 요구사항 줄임) +resources_requests_cpu=100m +resources_requests_memory=128Mi +resources_limits_cpu=500m +resources_limits_memory=512Mi diff --git a/smarketing-java/gradle/wrapper/gradle-wrapper.jar b/smarketing-java/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..9bbc975c742b298b441bfb90dbc124400a3751b9 GIT binary patch literal 43705 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 DHx0+F literal 0 HcmV?d00001 diff --git a/smarketing-java/gradle/wrapper/gradle-wrapper.properties b/smarketing-java/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..37f853b --- /dev/null +++ b/smarketing-java/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/smarketing-java/gradlew b/smarketing-java/gradlew new file mode 100644 index 0000000..faf9300 --- /dev/null +++ b/smarketing-java/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/smarketing-java/gradlew.bat b/smarketing-java/gradlew.bat new file mode 100644 index 0000000..9d21a21 --- /dev/null +++ b/smarketing-java/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/smarketing-java/marketing-content/build.gradle b/smarketing-java/marketing-content/build.gradle new file mode 100644 index 0000000..188d7bd --- /dev/null +++ b/smarketing-java/marketing-content/build.gradle @@ -0,0 +1,4 @@ +dependencies { + implementation project(':common') + runtimeOnly 'org.postgresql:postgresql' +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/MarketingContentServiceApplication.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/MarketingContentServiceApplication.java new file mode 100644 index 0000000..537a189 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/MarketingContentServiceApplication.java @@ -0,0 +1,29 @@ +package com.won.smarketing.content; + +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.EnableJpaAuditing; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +/** + * 마케팅 콘텐츠 서비스 메인 애플리케이션 클래스 + * Clean Architecture 패턴을 적용한 마케팅 콘텐츠 관리 서비스 + */ +@SpringBootApplication(scanBasePackages = { + "com.won.smarketing.content", + "com.won.smarketing.common" +}) +@EnableJpaRepositories(basePackages = { + "com.won.smarketing.content.infrastructure.repository" +}) +@EntityScan(basePackages = { + "com.won.smarketing.content.infrastructure.entity" +}) +@EnableJpaAuditing +public class MarketingContentServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(MarketingContentServiceApplication.class, args); + } +} diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/ContentQueryService.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/ContentQueryService.java new file mode 100644 index 0000000..c196e58 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/ContentQueryService.java @@ -0,0 +1,191 @@ +package com.won.smarketing.content.application.service; + +import com.won.smarketing.common.exception.BusinessException; +import com.won.smarketing.common.exception.ErrorCode; +import com.won.smarketing.content.application.usecase.ContentQueryUseCase; +import com.won.smarketing.content.domain.model.*; +import com.won.smarketing.content.domain.repository.ContentRepository; +import com.won.smarketing.content.presentation.dto.*; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 콘텐츠 조회 서비스 구현체 + * 콘텐츠 수정, 조회, 삭제 기능 구현 + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ContentQueryService implements ContentQueryUseCase { + + private final ContentRepository contentRepository; + + /** + * 콘텐츠 수정 + * + * @param contentId 수정할 콘텐츠 ID + * @param request 콘텐츠 수정 요청 + * @return 수정된 콘텐츠 정보 + */ + @Override + @Transactional + public ContentUpdateResponse updateContent(Long contentId, ContentUpdateRequest request) { + Content content = contentRepository.findById(ContentId.of(contentId)) + .orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND)); + + // 제목과 기간 업데이트 + content.updateTitle(request.getTitle()); + content.updatePeriod(request.getPromotionStartDate(), request.getPromotionEndDate()); + + Content updatedContent = contentRepository.save(content); + + return ContentUpdateResponse.builder() + .contentId(updatedContent.getId()) + //.contentType(updatedContent.getContentType().name()) + //.platform(updatedContent.getPlatform().name()) + .title(updatedContent.getTitle()) + .content(updatedContent.getContent()) + //.hashtags(updatedContent.getHashtags()) + //.images(updatedContent.getImages()) + .status(updatedContent.getStatus().name()) + .updatedAt(updatedContent.getUpdatedAt()) + .build(); + } + + /** + * 콘텐츠 목록 조회 + * + * @param contentType 콘텐츠 타입 + * @param platform 플랫폼 + * @param period 기간 + * @param sortBy 정렬 기준 + * @return 콘텐츠 목록 + */ + @Override + public List getContents(String contentType, String platform, String period, String sortBy) { + ContentType type = contentType != null ? ContentType.fromString(contentType) : null; + Platform platformEnum = platform != null ? Platform.fromString(platform) : null; + + List contents = contentRepository.findByFilters(type, platformEnum, period, sortBy); + + return contents.stream() + .map(this::toContentResponse) + .collect(Collectors.toList()); + } + + /** + * 진행 중인 콘텐츠 목록 조회 + * + * @param period 기간 + * @return 진행 중인 콘텐츠 목록 + */ + @Override + public List getOngoingContents(String period) { + List contents = contentRepository.findOngoingContents(period); + + return contents.stream() + .map(this::toOngoingContentResponse) + .collect(Collectors.toList()); + } + + /** + * 콘텐츠 상세 조회 + * + * @param contentId 콘텐츠 ID + * @return 콘텐츠 상세 정보 + */ + @Override + public ContentDetailResponse getContentDetail(Long contentId) { + Content content = contentRepository.findById(ContentId.of(contentId)) + .orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND)); + + return ContentDetailResponse.builder() + .contentId(content.getId()) + .contentType(content.getContentType().name()) + .platform(content.getPlatform().name()) + .title(content.getTitle()) + .content(content.getContent()) + .hashtags(content.getHashtags()) + .images(content.getImages()) + .status(content.getStatus().name()) + .creationConditions(toCreationConditionsDto(content.getCreationConditions())) + .createdAt(content.getCreatedAt()) + .build(); + } + + /** + * 콘텐츠 삭제 + * + * @param contentId 삭제할 콘텐츠 ID + */ + @Override + @Transactional + public void deleteContent(Long contentId) { + Content content = contentRepository.findById(ContentId.of(contentId)) + .orElseThrow(() -> new BusinessException(ErrorCode.CONTENT_NOT_FOUND)); + + contentRepository.deleteById(ContentId.of(contentId)); + } + + /** + * Content 엔티티를 ContentResponse DTO로 변환 + * + * @param content Content 엔티티 + * @return ContentResponse DTO + */ + private ContentResponse toContentResponse(Content content) { + return ContentResponse.builder() + .contentId(content.getId()) + .contentType(content.getContentType().name()) + .platform(content.getPlatform().name()) + .title(content.getTitle()) + .content(content.getContent()) + .hashtags(content.getHashtags()) + .images(content.getImages()) + .status(content.getStatus().name()) + .createdAt(content.getCreatedAt()) + .viewCount(0) // TODO: 실제 조회 수 구현 필요 + .build(); + } + + /** + * Content 엔티티를 OngoingContentResponse DTO로 변환 + * + * @param content Content 엔티티 + * @return OngoingContentResponse DTO + */ + private OngoingContentResponse toOngoingContentResponse(Content content) { + return OngoingContentResponse.builder() + .contentId(content.getId()) + .contentType(content.getContentType().name()) + .platform(content.getPlatform().name()) + .title(content.getTitle()) + .status(content.getStatus().name()) + .promotionStartDate(content.getPromotionStartDate()) + //.viewCount(0) // TODO: 실제 조회 수 구현 필요 + .build(); + } + + /** + * CreationConditions를 DTO로 변환 + * + * @param conditions CreationConditions 도메인 객체 + * @return CreationConditionsDto + */ + private ContentDetailResponse.CreationConditionsDto toCreationConditionsDto(CreationConditions conditions) { + if (conditions == null) { + return null; + } + + return ContentDetailResponse.CreationConditionsDto.builder() + .toneAndManner(conditions.getToneAndManner()) + .emotionIntensity(conditions.getEmotionIntensity()) + .eventName(conditions.getEventName()) + .build(); + } +} diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/PosterContentService.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/PosterContentService.java new file mode 100644 index 0000000..4db4d8a --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/PosterContentService.java @@ -0,0 +1,108 @@ +package com.won.smarketing.content.application.service; + +import com.won.smarketing.content.application.usecase.PosterContentUseCase; +import com.won.smarketing.content.domain.model.Content; +import com.won.smarketing.content.domain.model.ContentStatus; +import com.won.smarketing.content.domain.model.ContentType; +import com.won.smarketing.content.domain.model.CreationConditions; +import com.won.smarketing.content.domain.model.Platform; +import com.won.smarketing.content.domain.repository.ContentRepository; +import com.won.smarketing.content.domain.service.AiPosterGenerator; +import com.won.smarketing.content.presentation.dto.PosterContentCreateRequest; +import com.won.smarketing.content.presentation.dto.PosterContentCreateResponse; +import com.won.smarketing.content.presentation.dto.PosterContentSaveRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.Map; + +/** + * 포스터 콘텐츠 서비스 구현체 + * 홍보 포스터 생성 및 저장 기능 구현 + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PosterContentService implements PosterContentUseCase { + + private final ContentRepository contentRepository; + private final AiPosterGenerator aiPosterGenerator; + + /** + * 포스터 콘텐츠 생성 + * + * @param request 포스터 콘텐츠 생성 요청 + * @return 생성된 포스터 콘텐츠 정보 + */ + @Override + @Transactional + public PosterContentCreateResponse generatePosterContent(PosterContentCreateRequest request) { + // AI를 사용하여 포스터 생성 + String generatedPoster = aiPosterGenerator.generatePoster(request); + + // 다양한 사이즈의 포스터 생성 + Map posterSizes = aiPosterGenerator.generatePosterSizes(generatedPoster); + + // 생성 조건 정보 구성 + CreationConditions conditions = CreationConditions.builder() + .category(request.getCategory()) + .requirement(request.getRequirement()) + .toneAndManner(request.getToneAndManner()) + .emotionIntensity(request.getEmotionIntensity()) + .eventName(request.getEventName()) + .startDate(request.getStartDate()) + .endDate(request.getEndDate()) + .photoStyle(request.getPhotoStyle()) + .build(); + + return PosterContentCreateResponse.builder() + .contentId(null) // 임시 생성이므로 ID 없음 + .contentType(ContentType.POSTER.name()) + .title(request.getTitle()) + .posterImage(generatedPoster) + .posterSizes(posterSizes) + .status(ContentStatus.DRAFT.name()) + //.createdAt(LocalDateTime.now()) + .build(); + } + + /** + * 포스터 콘텐츠 저장 + * + * @param request 포스터 콘텐츠 저장 요청 + */ + @Override + @Transactional + public void savePosterContent(PosterContentSaveRequest request) { + // 생성 조건 정보 구성 + CreationConditions conditions = CreationConditions.builder() + .category(request.getCategory()) + .requirement(request.getRequirement()) + .toneAndManner(request.getToneAndManner()) + .emotionIntensity(request.getEmotionIntensity()) + .eventName(request.getEventName()) + .startDate(request.getStartDate()) + .endDate(request.getEndDate()) + .photoStyle(request.getPhotoStyle()) + .build(); + + // 콘텐츠 엔티티 생성 및 저장 + Content content = Content.builder() + .contentType(ContentType.POSTER) + .platform(Platform.GENERAL) // 포스터는 범용 + .title(request.getTitle()) + .content(null) // 포스터는 이미지가 주 콘텐츠 + .hashtags(null) + .images(request.getImages()) + .status(ContentStatus.PUBLISHED) + .creationConditions(conditions) + .storeId(request.getStoreId()) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .build(); + + contentRepository.save(content); + } +} diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/SnsContentService.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/SnsContentService.java new file mode 100644 index 0000000..dd8e603 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/SnsContentService.java @@ -0,0 +1,125 @@ +package com.won.smarketing.content.application.service; + +import com.won.smarketing.content.application.usecase.SnsContentUseCase; +import com.won.smarketing.content.domain.model.Content; +import com.won.smarketing.content.domain.model.ContentId; +import com.won.smarketing.content.domain.model.ContentStatus; +import com.won.smarketing.content.domain.model.ContentType; +import com.won.smarketing.content.domain.model.CreationConditions; +import com.won.smarketing.content.domain.model.Platform; +import com.won.smarketing.content.domain.repository.ContentRepository; +import com.won.smarketing.content.domain.service.AiContentGenerator; +import com.won.smarketing.content.presentation.dto.SnsContentCreateRequest; +import com.won.smarketing.content.presentation.dto.SnsContentCreateResponse; +import com.won.smarketing.content.presentation.dto.SnsContentSaveRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * SNS 콘텐츠 서비스 구현체 + * SNS 게시물 생성 및 저장 기능 구현 + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SnsContentService implements SnsContentUseCase { + + private final ContentRepository contentRepository; + private final AiContentGenerator aiContentGenerator; + + /** + * SNS 콘텐츠 생성 + * + * @param request SNS 콘텐츠 생성 요청 + * @return 생성된 SNS 콘텐츠 정보 + */ + @Override + @Transactional + public SnsContentCreateResponse generateSnsContent(SnsContentCreateRequest request) { + // AI를 사용하여 SNS 콘텐츠 생성 + String generatedContent = aiContentGenerator.generateSnsContent(request); + + // 플랫폼에 맞는 해시태그 생성 + Platform platform = Platform.fromString(request.getPlatform()); + List hashtags = aiContentGenerator.generateHashtags(generatedContent, platform); + + // 생성 조건 정보 구성 + CreationConditions conditions = CreationConditions.builder() + .category(request.getCategory()) + .requirement(request.getRequirement()) + .toneAndManner(request.getToneAndManner()) + .emotionIntensity(request.getEmotionIntensity()) + .eventName(request.getEventName()) + .startDate(request.getStartDate()) + .endDate(request.getEndDate()) + .build(); + + // 임시 콘텐츠 생성 (저장하지 않음) + Content content = Content.builder() +// .contentType(ContentType.SNS_POST) + .platform(platform) + .title(request.getTitle()) + .content(generatedContent) + .hashtags(hashtags) + .images(request.getImages()) + .status(ContentStatus.DRAFT) + .creationConditions(conditions) + .storeId(request.getStoreId()) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .build(); + + return SnsContentCreateResponse.builder() + .contentId(null) // 임시 생성이므로 ID 없음 + .contentType(content.getContentType().name()) + .platform(content.getPlatform().name()) + .title(content.getTitle()) + .content(content.getContent()) + .hashtags(content.getHashtags()) + .fixedImages(content.getImages()) + .status(content.getStatus().name()) + .createdAt(content.getCreatedAt()) + .build(); + } + + /** + * SNS 콘텐츠 저장 + * + * @param request SNS 콘텐츠 저장 요청 + */ + @Override + @Transactional + public void saveSnsContent(SnsContentSaveRequest request) { + // 생성 조건 정보 구성 + CreationConditions conditions = CreationConditions.builder() + .category(request.getCategory()) + .requirement(request.getRequirement()) + .toneAndManner(request.getToneAndManner()) + .emotionIntensity(request.getEmotionIntensity()) + .eventName(request.getEventName()) + .startDate(request.getStartDate()) + .endDate(request.getEndDate()) + .build(); + + // 콘텐츠 엔티티 생성 및 저장 + Content content = Content.builder() +// .contentType(ContentType.SNS_POST) + .platform(Platform.fromString(request.getPlatform())) + .title(request.getTitle()) + .content(request.getContent()) + .hashtags(request.getHashtags()) + .images(request.getImages()) + .status(ContentStatus.PUBLISHED) + .creationConditions(conditions) + .storeId(request.getStoreId()) + .createdAt(LocalDateTime.now()) + .updatedAt(LocalDateTime.now()) + .build(); + + contentRepository.save(content); + } +} diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/ContentQueryUseCase.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/ContentQueryUseCase.java new file mode 100644 index 0000000..0712961 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/ContentQueryUseCase.java @@ -0,0 +1,55 @@ +package com.won.smarketing.content.application.usecase; + +import com.won.smarketing.content.presentation.dto.*; + +import java.util.List; + +/** + * 콘텐츠 조회 관련 Use Case 인터페이스 + * 콘텐츠 수정, 조회, 삭제 기능 정의 + */ +public interface ContentQueryUseCase { + + /** + * 콘텐츠 수정 + * + * @param contentId 수정할 콘텐츠 ID + * @param request 콘텐츠 수정 요청 + * @return 수정된 콘텐츠 정보 + */ + ContentUpdateResponse updateContent(Long contentId, ContentUpdateRequest request); + + /** + * 콘텐츠 목록 조회 + * + * @param contentType 콘텐츠 타입 + * @param platform 플랫폼 + * @param period 기간 + * @param sortBy 정렬 기준 + * @return 콘텐츠 목록 + */ + List getContents(String contentType, String platform, String period, String sortBy); + + /** + * 진행 중인 콘텐츠 목록 조회 + * + * @param period 기간 + * @return 진행 중인 콘텐츠 목록 + */ + List getOngoingContents(String period); + + /** + * 콘텐츠 상세 조회 + * + * @param contentId 콘텐츠 ID + * @return 콘텐츠 상세 정보 + */ + ContentDetailResponse getContentDetail(Long contentId); + + /** + * 콘텐츠 삭제 + * + * @param contentId 삭제할 콘텐츠 ID + */ + void deleteContent(Long contentId); +} diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/PosterContentUseCase.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/PosterContentUseCase.java new file mode 100644 index 0000000..6bf2960 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/PosterContentUseCase.java @@ -0,0 +1,26 @@ +// marketing-content/src/main/java/com/won/smarketing/content/application/usecase/PosterContentUseCase.java +package com.won.smarketing.content.application.usecase; + +import com.won.smarketing.content.presentation.dto.PosterContentCreateRequest; +import com.won.smarketing.content.presentation.dto.PosterContentCreateResponse; +import com.won.smarketing.content.presentation.dto.PosterContentSaveRequest; + +/** + * 포스터 콘텐츠 관련 UseCase 인터페이스 + * Clean Architecture의 Application Layer에서 비즈니스 로직 정의 + */ +public interface PosterContentUseCase { + + /** + * 포스터 콘텐츠 생성 + * @param request 포스터 콘텐츠 생성 요청 + * @return 포스터 콘텐츠 생성 응답 + */ + PosterContentCreateResponse generatePosterContent(PosterContentCreateRequest request); + + /** + * 포스터 콘텐츠 저장 + * @param request 포스터 콘텐츠 저장 요청 + */ + void savePosterContent(PosterContentSaveRequest request); +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/SnsContentUseCase.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/SnsContentUseCase.java new file mode 100644 index 0000000..d2c6751 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/usecase/SnsContentUseCase.java @@ -0,0 +1,26 @@ +// marketing-content/src/main/java/com/won/smarketing/content/application/usecase/SnsContentUseCase.java +package com.won.smarketing.content.application.usecase; + +import com.won.smarketing.content.presentation.dto.SnsContentCreateRequest; +import com.won.smarketing.content.presentation.dto.SnsContentCreateResponse; +import com.won.smarketing.content.presentation.dto.SnsContentSaveRequest; + +/** + * SNS 콘텐츠 관련 UseCase 인터페이스 + * Clean Architecture의 Application Layer에서 비즈니스 로직 정의 + */ +public interface SnsContentUseCase { + + /** + * SNS 콘텐츠 생성 + * @param request SNS 콘텐츠 생성 요청 + * @return SNS 콘텐츠 생성 응답 + */ + SnsContentCreateResponse generateSnsContent(SnsContentCreateRequest request); + + /** + * SNS 콘텐츠 저장 + * @param request SNS 콘텐츠 저장 요청 + */ + void saveSnsContent(SnsContentSaveRequest request); +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/ContentConfig.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/ContentConfig.java new file mode 100644 index 0000000..3931d19 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/ContentConfig.java @@ -0,0 +1,9 @@ +package com.won.smarketing.content.config; + +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ComponentScan(basePackages = "com.won.smarketing.content") +public class ContentConfig { +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/ObjectMapperConfig.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/ObjectMapperConfig.java new file mode 100644 index 0000000..f9a77b8 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/config/ObjectMapperConfig.java @@ -0,0 +1,26 @@ + + +// marketing-content/src/main/java/com/won/smarketing/content/config/ObjectMapperConfig.java +package com.won.smarketing.content.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * ObjectMapper 설정 클래스 + * + * @author smarketing-team + * @version 1.0 + */ +@Configuration +public class ObjectMapperConfig { + + @Bean + public ObjectMapper objectMapper() { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + return objectMapper; + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java new file mode 100644 index 0000000..9a19b77 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java @@ -0,0 +1,163 @@ +// marketing-content/src/main/java/com/won/smarketing/content/domain/model/Content.java +package com.won.smarketing.content.domain.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * 콘텐츠 도메인 모델 + * + * Clean Architecture의 Domain Layer에 위치하는 핵심 엔티티 + * JPA 애노테이션을 제거하여 순수 도메인 모델로 유지 + * Infrastructure Layer에서 별도의 JPA 엔티티로 매핑 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Content { + + // ==================== 기본키 및 식별자 ==================== + private Long id; + + // ==================== 콘텐츠 분류 ==================== + private ContentType contentType; + private Platform platform; + + // ==================== 콘텐츠 내용 ==================== + private String title; + private String content; + + // ==================== 멀티미디어 및 메타데이터 ==================== + @Builder.Default + private List hashtags = new ArrayList<>(); + + @Builder.Default + private List images = new ArrayList<>(); + + // ==================== 상태 관리 ==================== + private ContentStatus status; + + // ==================== 생성 조건 ==================== + private CreationConditions creationConditions; + + // ==================== 매장 정보 ==================== + private Long storeId; + + // ==================== 프로모션 기간 ==================== + private LocalDateTime promotionStartDate; + private LocalDateTime promotionEndDate; + + // ==================== 메타데이터 ==================== + private LocalDateTime createdAt; + private LocalDateTime updatedAt; + + public Content(ContentId of, ContentType contentType, Platform platform, String title, String content, List strings, List strings1, ContentStatus contentStatus, CreationConditions conditions, Long storeId, LocalDateTime createdAt, LocalDateTime updatedAt) { + } + + // ==================== 비즈니스 메서드 ==================== + + /** + * 콘텐츠 제목 수정 + * @param newTitle 새로운 제목 + */ + public void updateTitle(String newTitle) { + if (newTitle == null || newTitle.trim().isEmpty()) { + throw new IllegalArgumentException("제목은 필수입니다."); + } + this.title = newTitle.trim(); + this.updatedAt = LocalDateTime.now(); + } + + /** + * 콘텐츠 내용 수정 + * @param newContent 새로운 내용 + */ + public void updateContent(String newContent) { + this.content = newContent; + this.updatedAt = LocalDateTime.now(); + } + + /** + * 프로모션 기간 설정 + * @param startDate 시작일 + * @param endDate 종료일 + */ + public void updatePeriod(LocalDateTime startDate, LocalDateTime endDate) { + if (startDate != null && endDate != null && startDate.isAfter(endDate)) { + throw new IllegalArgumentException("시작일은 종료일보다 이후일 수 없습니다."); + } + this.promotionStartDate = startDate; + this.promotionEndDate = endDate; + this.updatedAt = LocalDateTime.now(); + } + + /** + * 콘텐츠 상태 변경 + * @param newStatus 새로운 상태 + */ + public void updateStatus(ContentStatus newStatus) { + if (newStatus == null) { + throw new IllegalArgumentException("상태는 필수입니다."); + } + this.status = newStatus; + this.updatedAt = LocalDateTime.now(); + } + + /** + * 해시태그 추가 + * @param hashtag 추가할 해시태그 + */ + public void addHashtag(String hashtag) { + if (hashtag != null && !hashtag.trim().isEmpty()) { + if (this.hashtags == null) { + this.hashtags = new ArrayList<>(); + } + this.hashtags.add(hashtag.trim()); + this.updatedAt = LocalDateTime.now(); + } + } + + /** + * 이미지 추가 + * @param imageUrl 추가할 이미지 URL + */ + public void addImage(String imageUrl) { + if (imageUrl != null && !imageUrl.trim().isEmpty()) { + if (this.images == null) { + this.images = new ArrayList<>(); + } + this.images.add(imageUrl.trim()); + this.updatedAt = LocalDateTime.now(); + } + } + + /** + * 프로모션 진행 중 여부 확인 + * @return 현재 시간이 프로모션 기간 내에 있으면 true + */ + public boolean isPromotionActive() { + if (promotionStartDate == null || promotionEndDate == null) { + return false; + } + LocalDateTime now = LocalDateTime.now(); + return !now.isBefore(promotionStartDate) && !now.isAfter(promotionEndDate); + } + + /** + * 콘텐츠 게시 가능 여부 확인 + * @return 필수 정보가 모두 입력되어 있으면 true + */ + public boolean canBePublished() { + return title != null && !title.trim().isEmpty() + && contentType != null + && platform != null + && storeId != null; + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java new file mode 100644 index 0000000..2f07e2c --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java @@ -0,0 +1,51 @@ +// marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentId.java +package com.won.smarketing.content.domain.model; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 콘텐츠 ID 값 객체 + * Clean Architecture의 Domain Layer에서 식별자를 타입 안전하게 관리 + */ +@Getter +@RequiredArgsConstructor +@EqualsAndHashCode +public class ContentId { + + private final Long value; + + /** + * Long 값으로부터 ContentId 생성 + * @param value ID 값 + * @return ContentId 인스턴스 + */ + public static ContentId of(Long value) { + if (value == null || value <= 0) { + throw new IllegalArgumentException("ContentId는 양수여야 합니다."); + } + return new ContentId(value); + } + + /** + * 새로운 ContentId 생성 (ID가 없는 경우) + * @return null 값을 가진 ContentId + */ + public static ContentId newId() { + return new ContentId(null); + } + + /** + * ID 값 존재 여부 확인 + * @return ID가 null이 아니면 true + */ + public boolean hasValue() { + return value != null; + } + + @Override + public String toString() { + return "ContentId{" + "value=" + value + '}'; + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentStatus.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentStatus.java new file mode 100644 index 0000000..b235310 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentStatus.java @@ -0,0 +1,40 @@ +// marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentStatus.java +package com.won.smarketing.content.domain.model; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 콘텐츠 상태 열거형 + * Clean Architecture의 Domain Layer에 위치하는 비즈니스 규칙 + */ +@Getter +@RequiredArgsConstructor +public enum ContentStatus { + + DRAFT("임시저장"), + PUBLISHED("게시됨"), + SCHEDULED("예약됨"), + DELETED("삭제됨"), + PROCESSING("처리중"); + + private final String displayName; + + /** + * 문자열로부터 ContentStatus 변환 + * @param value 문자열 값 + * @return ContentStatus enum + * @throws IllegalArgumentException 유효하지 않은 값인 경우 + */ + public static ContentStatus fromString(String value) { + if (value == null) { + throw new IllegalArgumentException("ContentStatus 값은 null일 수 없습니다."); + } + + try { + return ContentStatus.valueOf(value.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("유효하지 않은 ContentStatus 값입니다: " + value); + } + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentType.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentType.java new file mode 100644 index 0000000..f70228b --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentType.java @@ -0,0 +1,39 @@ +// marketing-content/src/main/java/com/won/smarketing/content/domain/model/ContentType.java +package com.won.smarketing.content.domain.model; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 콘텐츠 타입 열거형 + * Clean Architecture의 Domain Layer에 위치하는 비즈니스 규칙 + */ +@Getter +@RequiredArgsConstructor +public enum ContentType { + + SNS("SNS 게시물"), + POSTER("홍보 포스터"), + VIDEO("동영상"), + BLOG("블로그 포스트"); + + private final String displayName; + + /** + * 문자열로부터 ContentType 변환 + * @param value 문자열 값 + * @return ContentType enum + * @throws IllegalArgumentException 유효하지 않은 값인 경우 + */ + public static ContentType fromString(String value) { + if (value == null) { + throw new IllegalArgumentException("ContentType 값은 null일 수 없습니다."); + } + + try { + return ContentType.valueOf(value.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("유효하지 않은 ContentType 값입니다: " + value); + } + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java new file mode 100644 index 0000000..d7a9543 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java @@ -0,0 +1,58 @@ +// marketing-content/src/main/java/com/won/smarketing/content/domain/model/CreationConditions.java +package com.won.smarketing.content.domain.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +/** + * 콘텐츠 생성 조건 도메인 모델 + * Clean Architecture의 Domain Layer에 위치하는 값 객체 + * + * JPA 애노테이션을 제거하여 순수 도메인 모델로 유지 + * Infrastructure Layer의 JPA 엔티티는 별도로 관리 + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class CreationConditions { + + private String id; + private String category; + private String requirement; + private String toneAndManner; + private String emotionIntensity; + private String eventName; + private LocalDate startDate; + private LocalDate endDate; + private String photoStyle; + private String promotionType; + + public CreationConditions(String category, String requirement, String toneAndManner, String emotionIntensity, String eventName, LocalDate startDate, LocalDate endDate, String photoStyle, String promotionType) { + } + + /** + * 이벤트 기간 유효성 검증 + * @return 시작일이 종료일보다 이전이거나 같으면 true + */ + public boolean isValidEventPeriod() { + if (startDate == null || endDate == null) { + return true; + } + return !startDate.isAfter(endDate); + } + + /** + * 이벤트 조건 유무 확인 + * @return 이벤트명이나 날짜가 설정되어 있으면 true + */ + public boolean hasEventInfo() { + return eventName != null && !eventName.trim().isEmpty() + || startDate != null + || endDate != null; + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Platform.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Platform.java new file mode 100644 index 0000000..66e266c --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/model/Platform.java @@ -0,0 +1,41 @@ +// marketing-content/src/main/java/com/won/smarketing/content/domain/model/Platform.java +package com.won.smarketing.content.domain.model; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 플랫폼 열거형 + * Clean Architecture의 Domain Layer에 위치하는 비즈니스 규칙 + */ +@Getter +@RequiredArgsConstructor +public enum Platform { + + INSTAGRAM("인스타그램"), + NAVER_BLOG("네이버 블로그"), + FACEBOOK("페이스북"), + KAKAO_STORY("카카오스토리"), + YOUTUBE("유튜브"), + GENERAL("일반"); + + private final String displayName; + + /** + * 문자열로부터 Platform 변환 + * @param value 문자열 값 + * @return Platform enum + * @throws IllegalArgumentException 유효하지 않은 값인 경우 + */ + public static Platform fromString(String value) { + if (value == null) { + throw new IllegalArgumentException("Platform 값은 null일 수 없습니다."); + } + + try { + return Platform.valueOf(value.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("유효하지 않은 Platform 값입니다: " + value); + } + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/ContentRepository.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/ContentRepository.java new file mode 100644 index 0000000..a2bfc43 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/ContentRepository.java @@ -0,0 +1,54 @@ +// marketing-content/src/main/java/com/won/smarketing/content/domain/repository/ContentRepository.java +package com.won.smarketing.content.domain.repository; + +import com.won.smarketing.content.domain.model.Content; +import com.won.smarketing.content.domain.model.ContentId; +import com.won.smarketing.content.domain.model.ContentType; +import com.won.smarketing.content.domain.model.Platform; + +import java.util.List; +import java.util.Optional; + +/** + * 콘텐츠 리포지토리 인터페이스 + * Clean Architecture의 Domain Layer에서 데이터 접근 정의 + */ +public interface ContentRepository { + + /** + * 콘텐츠 저장 + * @param content 저장할 콘텐츠 + * @return 저장된 콘텐츠 + */ + Content save(Content content); + + /** + * ID로 콘텐츠 조회 + * @param id 콘텐츠 ID + * @return 조회된 콘텐츠 + */ + Optional findById(ContentId id); + + /** + * 필터 조건으로 콘텐츠 목록 조회 + * @param contentType 콘텐츠 타입 + * @param platform 플랫폼 + * @param period 기간 + * @param sortBy 정렬 기준 + * @return 콘텐츠 목록 + */ + List findByFilters(ContentType contentType, Platform platform, String period, String sortBy); + + /** + * 진행 중인 콘텐츠 목록 조회 + * @param period 기간 + * @return 진행 중인 콘텐츠 목록 + */ + List findOngoingContents(String period); + + /** + * ID로 콘텐츠 삭제 + * @param id 삭제할 콘텐츠 ID + */ + void deleteById(ContentId id); +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/SpringDataContentRepository.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/SpringDataContentRepository.java new file mode 100644 index 0000000..d3a6e42 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/repository/SpringDataContentRepository.java @@ -0,0 +1,38 @@ +package com.won.smarketing.content.domain.repository; +import com.won.smarketing.content.infrastructure.entity.ContentEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** + * Spring Data JPA ContentRepository + * JPA 기반 콘텐츠 데이터 접근 + */ +@Repository +public interface SpringDataContentRepository extends JpaRepository { + + /** + * 매장별 콘텐츠 조회 + * + * @param storeId 매장 ID + * @return 콘텐츠 목록 + */ + List findByStoreId(Long storeId); + + /** + * 콘텐츠 타입별 조회 + * + * @param contentType 콘텐츠 타입 + * @return 콘텐츠 목록 + */ + List findByContentType(String contentType); + + /** + * 플랫폼별 조회 + * + * @param platform 플랫폼 + * @return 콘텐츠 목록 + */ + List findByPlatform(String platform); +} diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/AiContentGenerator.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/AiContentGenerator.java new file mode 100644 index 0000000..677853a --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/AiContentGenerator.java @@ -0,0 +1,30 @@ +package com.won.smarketing.content.domain.service; + +import com.won.smarketing.content.domain.model.Platform; +import com.won.smarketing.content.presentation.dto.SnsContentCreateRequest; + +import java.util.List; + +/** + * AI 콘텐츠 생성 도메인 서비스 인터페이스 + * SNS 콘텐츠 생성 및 해시태그 생성 기능 정의 + */ +public interface AiContentGenerator { + + /** + * SNS 콘텐츠 생성 + * + * @param request SNS 콘텐츠 생성 요청 + * @return 생성된 콘텐츠 + */ + String generateSnsContent(SnsContentCreateRequest request); + + /** + * 플랫폼별 해시태그 생성 + * + * @param content 콘텐츠 내용 + * @param platform 플랫폼 + * @return 해시태그 목록 + */ + List generateHashtags(String content, Platform platform); +} diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/AiPosterGenerator.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/AiPosterGenerator.java new file mode 100644 index 0000000..6c0f1cb --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/domain/service/AiPosterGenerator.java @@ -0,0 +1,28 @@ +package com.won.smarketing.content.domain.service; + +import com.won.smarketing.content.presentation.dto.PosterContentCreateRequest; + +import java.util.Map; + +/** + * AI 포스터 생성 도메인 서비스 인터페이스 + * 홍보 포스터 생성 및 다양한 사이즈 생성 기능 정의 + */ +public interface AiPosterGenerator { + + /** + * 포스터 생성 + * + * @param request 포스터 생성 요청 + * @return 생성된 포스터 이미지 URL + */ + String generatePoster(PosterContentCreateRequest request); + + /** + * 다양한 사이즈의 포스터 생성 + * + * @param baseImage 기본 이미지 + * @return 사이즈별 포스터 URL 맵 + */ + Map generatePosterSizes(String baseImage); +} diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentConditionsJpaEntity.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentConditionsJpaEntity.java new file mode 100644 index 0000000..b549b05 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentConditionsJpaEntity.java @@ -0,0 +1,84 @@ +// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentConditionsJpaEntity.java +package com.won.smarketing.content.infrastructure.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDate; + +/** + * 콘텐츠 생성 조건 JPA 엔티티 + * Infrastructure Layer에서 데이터베이스 매핑을 담당 + */ +@Entity +@Table(name = "content_conditions") +@Getter +@Setter +public class ContentConditionsJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "content_id", nullable = false) + private ContentJpaEntity content; + + @Column(name = "category", length = 100) + private String category; + + @Column(name = "requirement", columnDefinition = "TEXT") + private String requirement; + + @Column(name = "tone_and_manner", length = 100) + private String toneAndManner; + + @Column(name = "emotion_intensity", length = 50) + private String emotionIntensity; + + @Column(name = "event_name", length = 200) + private String eventName; + + @Column(name = "start_date") + private LocalDate startDate; + + @Column(name = "end_date") + private LocalDate endDate; + + @Column(name = "photo_style", length = 100) + private String photoStyle; + + @Column(name = "promotion_type", length = 100) + private String promotionType; + + // 생성자 + public ContentConditionsJpaEntity(ContentJpaEntity content, String category, String requirement, + String toneAndManner, String emotionIntensity, String eventName, + LocalDate startDate, LocalDate endDate, String photoStyle, String promotionType) { + this.content = content; + this.category = category; + this.requirement = requirement; + this.toneAndManner = toneAndManner; + this.emotionIntensity = emotionIntensity; + this.eventName = eventName; + this.startDate = startDate; + this.endDate = endDate; + this.photoStyle = photoStyle; + this.promotionType = promotionType; + } + + public ContentConditionsJpaEntity() { + + } + + // 팩토리 메서드 + public static ContentConditionsJpaEntity create(ContentJpaEntity content, String category, String requirement, + String toneAndManner, String emotionIntensity, String eventName, + LocalDate startDate, LocalDate endDate, String photoStyle, String promotionType) { + return new ContentConditionsJpaEntity(content, category, requirement, toneAndManner, emotionIntensity, + eventName, startDate, endDate, photoStyle, promotionType); + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentEntity.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentEntity.java new file mode 100644 index 0000000..ba941d4 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentEntity.java @@ -0,0 +1,60 @@ +package com.won.smarketing.content.infrastructure.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +/** + * 콘텐츠 엔티티 + * 콘텐츠 정보를 데이터베이스에 저장하기 위한 JPA 엔티티 + */ +@Entity +@Table(name = "contents") +@Data +@NoArgsConstructor +@AllArgsConstructor +@EntityListeners(AuditingEntityListener.class) +public class ContentEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "content_type", nullable = false) + private String contentType; + + @Column(name = "platform", nullable = false) + private String platform; + + @Column(name = "title", nullable = false) + private String title; + + @Column(name = "content", columnDefinition = "TEXT") + private String content; + + @Column(name = "hashtags") + private String hashtags; + + @Column(name = "images", columnDefinition = "TEXT") + private String images; + + @Column(name = "status", nullable = false) + private String status; + + @Column(name = "store_id", nullable = false) + private Long storeId; + + @CreatedDate + @Column(name = "created_at", updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at") + private LocalDateTime updatedAt; +} diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentJpaEntity.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentJpaEntity.java new file mode 100644 index 0000000..bcc8499 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/entity/ContentJpaEntity.java @@ -0,0 +1,70 @@ +package com.won.smarketing.content.infrastructure.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; +import java.util.Date; + +/** + * 콘텐츠 JPA 엔티티 + */ +@Entity +@Table(name = "contents") +@Getter +@Setter +@EntityListeners(AuditingEntityListener.class) +public class ContentJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "store_id", nullable = false) + private Long storeId; + + @Column(name = "content_type", nullable = false, length = 50) + private String contentType; + + @Column(name = "platform", length = 50) + private String platform; + + @Column(name = "title", length = 500) + private String title; + + @Column(name = "PromotionStartDate") + private LocalDateTime PromotionStartDate; + + @Column(name = "PromotionEndDate") + private LocalDateTime PromotionEndDate; + + @Column(name = "content", columnDefinition = "TEXT") + private String content; + + @Column(name = "hashtags", columnDefinition = "TEXT") + private String hashtags; + + @Column(name = "images", columnDefinition = "TEXT") + private String images; + + @Column(name = "status", nullable = false, length = 20) + private String status; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + // CreationConditions와의 관계 - OneToOne으로 별도 엔티티로 관리 + @OneToOne(mappedBy = "content", cascade = CascadeType.ALL, fetch = FetchType.LAZY) + private ContentConditionsJpaEntity conditions; +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiContentGenerator.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiContentGenerator.java new file mode 100644 index 0000000..b1d0e6d --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiContentGenerator.java @@ -0,0 +1,32 @@ +// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiContentGenerator.java +package com.won.smarketing.content.infrastructure.external; + +import com.won.smarketing.content.domain.model.Platform; +import com.won.smarketing.content.domain.model.CreationConditions; + +import java.util.List; + +/** + * AI 콘텐츠 생성 인터페이스 + * Clean Architecture의 Infrastructure Layer에서 외부 AI 서비스와의 연동 정의 + */ +public interface AiContentGenerator { + + /** + * SNS 콘텐츠 생성 + * @param title 제목 + * @param category 카테고리 + * @param platform 플랫폼 + * @param conditions 생성 조건 + * @return 생성된 콘텐츠 텍스트 + */ + String generateSnsContent(String title, String category, Platform platform, CreationConditions conditions); + + /** + * 해시태그 생성 + * @param content 콘텐츠 내용 + * @param platform 플랫폼 + * @return 생성된 해시태그 목록 + */ + List generateHashtags(String content, Platform platform); +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiPosterGenerator.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiPosterGenerator.java new file mode 100644 index 0000000..8bbe931 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiPosterGenerator.java @@ -0,0 +1,29 @@ +// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/AiPosterGenerator.java +package com.won.smarketing.content.infrastructure.external; + +import com.won.smarketing.content.domain.model.CreationConditions; + +import java.util.Map; + +/** + * AI 포스터 생성 인터페이스 + * Clean Architecture의 Infrastructure Layer에서 외부 AI 서비스와의 연동 정의 + */ +public interface AiPosterGenerator { + + /** + * 포스터 이미지 생성 + * @param title 제목 + * @param category 카테고리 + * @param conditions 생성 조건 + * @return 생성된 포스터 이미지 URL + */ + String generatePoster(String title, String category, CreationConditions conditions); + + /** + * 포스터 다양한 사이즈 생성 + * @param originalImage 원본 이미지 URL + * @return 사이즈별 이미지 URL 맵 + */ + Map generatePosterSizes(String originalImage); +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiContentGenerator.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiContentGenerator.java new file mode 100644 index 0000000..9d72f1f --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiContentGenerator.java @@ -0,0 +1,95 @@ +// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiContentGenerator.java +package com.won.smarketing.content.infrastructure.external; + +// 수정: domain 패키지의 인터페이스를 import +import com.won.smarketing.content.domain.service.AiContentGenerator; +import com.won.smarketing.content.domain.model.Platform; +import com.won.smarketing.content.presentation.dto.SnsContentCreateRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Arrays; +import java.util.List; + +/** + * Claude AI를 활용한 콘텐츠 생성 구현체 + * Clean Architecture의 Infrastructure Layer에 위치 + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class ClaudeAiContentGenerator implements AiContentGenerator { + + /** + * SNS 콘텐츠 생성 + */ + @Override + public String generateSnsContent(SnsContentCreateRequest request) { + try { + String prompt = buildContentPrompt(request); + return generateDummySnsContent(request.getTitle(), Platform.fromString(request.getPlatform())); + } catch (Exception e) { + log.error("AI 콘텐츠 생성 실패: {}", e.getMessage(), e); + return generateFallbackContent(request.getTitle(), Platform.fromString(request.getPlatform())); + } + } + + /** + * 플랫폼별 해시태그 생성 + */ + @Override + public List generateHashtags(String content, Platform platform) { + try { + return generateDummyHashtags(platform); + } catch (Exception e) { + log.error("해시태그 생성 실패: {}", e.getMessage(), e); + return generateFallbackHashtags(); + } + } + + private String buildContentPrompt(SnsContentCreateRequest request) { + StringBuilder prompt = new StringBuilder(); + prompt.append("제목: ").append(request.getTitle()).append("\n"); + prompt.append("카테고리: ").append(request.getCategory()).append("\n"); + prompt.append("플랫폼: ").append(request.getPlatform()).append("\n"); + + if (request.getRequirement() != null) { + prompt.append("요구사항: ").append(request.getRequirement()).append("\n"); + } + + if (request.getToneAndManner() != null) { + prompt.append("톤앤매너: ").append(request.getToneAndManner()).append("\n"); + } + + return prompt.toString(); + } + + private String generateDummySnsContent(String title, Platform platform) { + String baseContent = "🌟 " + title + "를 소개합니다! 🌟\n\n" + + "저희 매장에서 특별한 경험을 만나보세요.\n" + + "고객 여러분의 소중한 시간을 더욱 특별하게 만들어드리겠습니다.\n\n"; + + if (platform == Platform.INSTAGRAM) { + return baseContent + "더 많은 정보는 프로필 링크에서 확인하세요! 📸"; + } else { + return baseContent + "자세한 내용은 저희 블로그를 방문해 주세요! ✨"; + } + } + + private String generateFallbackContent(String title, Platform platform) { + return title + "에 대한 멋진 콘텐츠입니다. 많은 관심 부탁드립니다!"; + } + + private List generateDummyHashtags(Platform platform) { + if (platform == Platform.INSTAGRAM) { + return Arrays.asList("#맛집", "#데일리", "#소상공인", "#추천", "#인스타그램"); + } else { + return Arrays.asList("#맛집추천", "#블로그", "#리뷰", "#맛있는곳", "#소상공인응원"); + } + } + + private List generateFallbackHashtags() { + return Arrays.asList("#소상공인", "#마케팅", "#홍보"); + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java new file mode 100644 index 0000000..7495966 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java @@ -0,0 +1,86 @@ +// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java +package com.won.smarketing.content.infrastructure.external; + +import com.won.smarketing.content.domain.service.AiPosterGenerator; // 도메인 인터페이스 import +import com.won.smarketing.content.presentation.dto.PosterContentCreateRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.Map; + +/** + * Claude AI를 활용한 포스터 생성 구현체 + * Clean Architecture의 Infrastructure Layer에 위치 + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class ClaudeAiPosterGenerator implements AiPosterGenerator { + + /** + * 포스터 생성 + * + * @param request 포스터 생성 요청 + * @return 생성된 포스터 이미지 URL + */ + @Override + public String generatePoster(PosterContentCreateRequest request) { + try { + // Claude AI API 호출 로직 + String prompt = buildPosterPrompt(request); + + // TODO: 실제 Claude AI API 호출 + // 현재는 더미 데이터 반환 + return generateDummyPosterUrl(request.getTitle()); + + } catch (Exception e) { + log.error("AI 포스터 생성 실패: {}", e.getMessage(), e); + return generateFallbackPosterUrl(); + } + } + + /** + * 다양한 사이즈의 포스터 생성 + * + * @param baseImage 기본 이미지 + * @return 사이즈별 포스터 URL 맵 + */ + @Override + public Map generatePosterSizes(String baseImage) { + Map sizes = new HashMap<>(); + + // 다양한 사이즈 생성 (더미 구현) + sizes.put("instagram_square", baseImage + "_1080x1080.jpg"); + sizes.put("instagram_story", baseImage + "_1080x1920.jpg"); + sizes.put("facebook_post", baseImage + "_1200x630.jpg"); + sizes.put("a4_poster", baseImage + "_2480x3508.jpg"); + + return sizes; + } + + private String buildPosterPrompt(PosterContentCreateRequest request) { + StringBuilder prompt = new StringBuilder(); + prompt.append("포스터 제목: ").append(request.getTitle()).append("\n"); + prompt.append("카테고리: ").append(request.getCategory()).append("\n"); + + if (request.getRequirement() != null) { + prompt.append("요구사항: ").append(request.getRequirement()).append("\n"); + } + + if (request.getToneAndManner() != null) { + prompt.append("톤앤매너: ").append(request.getToneAndManner()).append("\n"); + } + + return prompt.toString(); + } + + private String generateDummyPosterUrl(String title) { + return "https://dummy-ai-service.com/posters/" + title.hashCode() + ".jpg"; + } + + private String generateFallbackPosterUrl() { + return "https://dummy-ai-service.com/posters/fallback.jpg"; + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/mapper/ContentMapper.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/mapper/ContentMapper.java new file mode 100644 index 0000000..44fdb68 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/mapper/ContentMapper.java @@ -0,0 +1,213 @@ +// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/mapper/ContentMapper.java +package com.won.smarketing.content.infrastructure.mapper; + +import com.won.smarketing.content.domain.model.*; +import com.won.smarketing.content.infrastructure.entity.ContentConditionsJpaEntity; +import com.won.smarketing.content.infrastructure.entity.ContentJpaEntity; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.Collections; +import java.util.List; + +/** + * 콘텐츠 도메인-엔티티 매퍼 + * Clean Architecture에서 Infrastructure Layer와 Domain Layer 간 변환 담당 + * + * @author smarketing-team + * @version 1.0 + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class ContentMapper { + + private final ObjectMapper objectMapper; + + /** + * 도메인 모델을 JPA 엔티티로 변환 + * + * @param content 도메인 콘텐츠 + * @return JPA 엔티티 + */ + public ContentJpaEntity toEntity(Content content) { + if (content == null) { + return null; + } + + ContentJpaEntity entity = new ContentJpaEntity(); + + // 기본 필드 매핑 + if (content.getId() != null) { + entity.setId(content.getId()); + } + entity.setStoreId(content.getStoreId()); + entity.setContentType(content.getContentType() != null ? content.getContentType().name() : null); + entity.setPlatform(content.getPlatform() != null ? content.getPlatform().name() : null); + entity.setTitle(content.getTitle()); + entity.setContent(content.getContent()); + entity.setStatus(content.getStatus() != null ? content.getStatus().name() : "DRAFT"); + entity.setPromotionStartDate(content.getPromotionStartDate()); + entity.setPromotionEndDate(content.getPromotionEndDate()); + entity.setCreatedAt(content.getCreatedAt()); + entity.setUpdatedAt(content.getUpdatedAt()); + + // 컬렉션 필드를 JSON으로 변환 + entity.setHashtags(convertListToJson(content.getHashtags())); + entity.setImages(convertListToJson(content.getImages())); + + // 생성 조건 정보 매핑 + if (content.getCreationConditions() != null) { + ContentConditionsJpaEntity conditionsEntity = mapToConditionsEntity(content.getCreationConditions()); + conditionsEntity.setContent(entity); + entity.setConditions(conditionsEntity); + } + + return entity; + } + + /** + * JPA 엔티티를 도메인 모델로 변환 + * + * @param entity JPA 엔티티 + * @return 도메인 모델 + */ + public Content toDomain(ContentJpaEntity entity) { + if (entity == null) { + return null; + } + + return Content.builder() + .id(entity.getId()) + .storeId(entity.getStoreId()) + .contentType(parseContentType(entity.getContentType())) + .platform(parsePlatform(entity.getPlatform())) + .title(entity.getTitle()) + .content(entity.getContent()) + .hashtags(convertJsonToList(entity.getHashtags())) + .images(convertJsonToList(entity.getImages())) + .status(parseContentStatus(entity.getStatus())) + .promotionStartDate(entity.getPromotionStartDate()) + .promotionEndDate(entity.getPromotionEndDate()) + .creationConditions(mapToConditionsDomain(entity.getConditions())) + .createdAt(entity.getCreatedAt()) + .updatedAt(entity.getUpdatedAt()) + .build(); + } + + /** + * CreationConditions 도메인을 JPA 엔티티로 변환 + */ + private ContentConditionsJpaEntity mapToConditionsEntity(CreationConditions conditions) { + ContentConditionsJpaEntity entity = new ContentConditionsJpaEntity(); + entity.setCategory(conditions.getCategory()); + entity.setRequirement(conditions.getRequirement()); + entity.setToneAndManner(conditions.getToneAndManner()); + entity.setEmotionIntensity(conditions.getEmotionIntensity()); + entity.setEventName(conditions.getEventName()); + entity.setStartDate(conditions.getStartDate()); + entity.setEndDate(conditions.getEndDate()); + entity.setPhotoStyle(conditions.getPhotoStyle()); + entity.setPromotionType(conditions.getPromotionType()); + return entity; + } + + /** + * CreationConditions JPA 엔티티를 도메인으로 변환 + */ + private CreationConditions mapToConditionsDomain(ContentConditionsJpaEntity entity) { + if (entity == null) { + return null; + } + + return CreationConditions.builder() + .category(entity.getCategory()) + .requirement(entity.getRequirement()) + .toneAndManner(entity.getToneAndManner()) + .emotionIntensity(entity.getEmotionIntensity()) + .eventName(entity.getEventName()) + .startDate(entity.getStartDate()) + .endDate(entity.getEndDate()) + .photoStyle(entity.getPhotoStyle()) + .promotionType(entity.getPromotionType()) + .build(); + } + + /** + * List를 JSON 문자열로 변환 + */ + private String convertListToJson(List list) { + if (list == null || list.isEmpty()) { + return null; + } + try { + return objectMapper.writeValueAsString(list); + } catch (Exception e) { + log.warn("Failed to convert list to JSON: {}", e.getMessage()); + return null; + } + } + + /** + * JSON 문자열을 List로 변환 + */ + private List convertJsonToList(String json) { + if (json == null || json.trim().isEmpty()) { + return Collections.emptyList(); + } + try { + return objectMapper.readValue(json, new TypeReference>() {}); + } catch (Exception e) { + log.warn("Failed to convert JSON to list: {}", e.getMessage()); + return Collections.emptyList(); + } + } + + /** + * 문자열을 ContentType 열거형으로 변환 + */ + private ContentType parseContentType(String contentType) { + if (contentType == null) { + return null; + } + try { + return ContentType.valueOf(contentType); + } catch (IllegalArgumentException e) { + log.warn("Unknown content type: {}", contentType); + return null; + } + } + + /** + * 문자열을 Platform 열거형으로 변환 + */ + private Platform parsePlatform(String platform) { + if (platform == null) { + return null; + } + try { + return Platform.valueOf(platform); + } catch (IllegalArgumentException e) { + log.warn("Unknown platform: {}", platform); + return null; + } + } + + /** + * 문자열을 ContentStatus 열거형으로 변환 + */ + private ContentStatus parseContentStatus(String status) { + if (status == null) { + return ContentStatus.DRAFT; + } + try { + return ContentStatus.valueOf(status); + } catch (IllegalArgumentException e) { + log.warn("Unknown content status: {}", status); + return ContentStatus.DRAFT; + } + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepository.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepository.java new file mode 100644 index 0000000..f3f38ed --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepository.java @@ -0,0 +1,147 @@ +// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepository.java +package com.won.smarketing.content.infrastructure.repository; + +import com.won.smarketing.content.domain.model.Content; +import com.won.smarketing.content.domain.model.ContentId; +import com.won.smarketing.content.domain.model.ContentType; +import com.won.smarketing.content.domain.model.Platform; +import com.won.smarketing.content.domain.repository.ContentRepository; +import com.won.smarketing.content.infrastructure.entity.ContentJpaEntity; +import com.won.smarketing.content.infrastructure.mapper.ContentMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * JPA를 활용한 콘텐츠 리포지토리 구현체 + * Clean Architecture의 Infrastructure Layer에 위치 + * JPA 엔티티와 도메인 모델 간 변환을 위해 ContentMapper 사용 + */ +@Repository +@RequiredArgsConstructor +@Slf4j +public class JpaContentRepository implements ContentRepository { + + private final JpaContentRepositoryInterface jpaRepository; + private final ContentMapper contentMapper; + + /** + * 콘텐츠 저장 + * @param content 저장할 도메인 콘텐츠 + * @return 저장된 도메인 콘텐츠 + */ + @Override + public Content save(Content content) { + log.debug("Saving content: {}", content.getTitle()); + + // 도메인 모델을 JPA 엔티티로 변환 + ContentJpaEntity entity = contentMapper.toEntity(content); + + // JPA로 저장 + ContentJpaEntity savedEntity = jpaRepository.save(entity); + + // JPA 엔티티를 도메인 모델로 변환하여 반환 + Content savedContent = contentMapper.toDomain(savedEntity); + + log.debug("Content saved with ID: {}", savedContent.getId()); + return savedContent; + } + + /** + * ID로 콘텐츠 조회 + * @param id 콘텐츠 ID + * @return 조회된 도메인 콘텐츠 + */ + @Override + public Optional findById(ContentId id) { + log.debug("Finding content by ID: {}", id.getValue()); + + return jpaRepository.findById(id.getValue()) + .map(contentMapper::toDomain); + } + + /** + * 필터 조건으로 콘텐츠 목록 조회 + * @param contentType 콘텐츠 타입 + * @param platform 플랫폼 + * @param period 기간 (현재는 사용하지 않음) + * @param sortBy 정렬 기준 (현재는 사용하지 않음) + * @return 도메인 콘텐츠 목록 + */ + @Override + public List findByFilters(ContentType contentType, Platform platform, String period, String sortBy) { + log.debug("Finding contents with filters - contentType: {}, platform: {}", contentType, platform); + + String contentTypeStr = contentType != null ? contentType.name() : null; + String platformStr = platform != null ? platform.name() : null; + + List entities = jpaRepository.findByFilters(contentTypeStr, platformStr, null); + + return entities.stream() + .map(contentMapper::toDomain) + .collect(Collectors.toList()); + } + + /** + * 진행 중인 콘텐츠 목록 조회 + * @param period 기간 (현재는 사용하지 않음) + * @return 진행 중인 도메인 콘텐츠 목록 + */ + @Override + public List findOngoingContents(String period) { + log.debug("Finding ongoing contents"); + + List entities = jpaRepository.findOngoingContents(); + + return entities.stream() + .map(contentMapper::toDomain) + .collect(Collectors.toList()); + } + + /** + * ID로 콘텐츠 삭제 + * @param id 삭제할 콘텐츠 ID + */ + @Override + public void deleteById(ContentId id) { + log.debug("Deleting content by ID: {}", id.getValue()); + + jpaRepository.deleteById(id.getValue()); + + log.debug("Content deleted successfully"); + } + + /** + * 매장 ID로 콘텐츠 목록 조회 (추가 메서드) + * @param storeId 매장 ID + * @return 도메인 콘텐츠 목록 + */ + public List findByStoreId(Long storeId) { + log.debug("Finding contents by store ID: {}", storeId); + + List entities = jpaRepository.findByStoreId(storeId); + + return entities.stream() + .map(contentMapper::toDomain) + .collect(Collectors.toList()); + } + + /** + * 콘텐츠 타입으로 조회 (추가 메서드) + * @param contentType 콘텐츠 타입 + * @return 도메인 콘텐츠 목록 + */ + public List findByContentType(ContentType contentType) { + log.debug("Finding contents by type: {}", contentType); + + List entities = jpaRepository.findByContentType(contentType.name()); + + return entities.stream() + .map(contentMapper::toDomain) + .collect(Collectors.toList()); + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepositoryInterface.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepositoryInterface.java new file mode 100644 index 0000000..37c4e74 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepositoryInterface.java @@ -0,0 +1,87 @@ +// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/repository/JpaContentRepositoryInterface.java +package com.won.smarketing.content.infrastructure.repository; + +import com.won.smarketing.content.infrastructure.entity.ContentJpaEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +/** + * Spring Data JPA 콘텐츠 리포지토리 인터페이스 + * Clean Architecture의 Infrastructure Layer에 위치 + * JPA 엔티티(ContentJpaEntity)를 사용하여 데이터베이스 접근 + */ +public interface JpaContentRepositoryInterface extends JpaRepository { + + /** + * 매장 ID로 콘텐츠 목록 조회 + * @param storeId 매장 ID + * @return 콘텐츠 엔티티 목록 + */ + List findByStoreId(Long storeId); + + /** + * 콘텐츠 타입으로 조회 + * @param contentType 콘텐츠 타입 + * @return 콘텐츠 엔티티 목록 + */ + List findByContentType(String contentType); + + /** + * 플랫폼으로 조회 + * @param platform 플랫폼 + * @return 콘텐츠 엔티티 목록 + */ + List findByPlatform(String platform); + + /** + * 상태로 조회 + * @param status 상태 + * @return 콘텐츠 엔티티 목록 + */ + List findByStatus(String status); + + /** + * 필터 조건으로 콘텐츠 목록 조회 + * @param contentType 콘텐츠 타입 (null 가능) + * @param platform 플랫폼 (null 가능) + * @param status 상태 (null 가능) + * @return 콘텐츠 엔티티 목록 + */ + @Query("SELECT c FROM ContentJpaEntity c WHERE " + + "(:contentType IS NULL OR c.contentType = :contentType) AND " + + "(:platform IS NULL OR c.platform = :platform) AND " + + "(:status IS NULL OR c.status = :status) " + + "ORDER BY c.createdAt DESC") + List findByFilters(@Param("contentType") String contentType, + @Param("platform") String platform, + @Param("status") String status); + + /** + * 진행 중인 콘텐츠 목록 조회 (발행된 상태의 콘텐츠) + * @return 진행 중인 콘텐츠 엔티티 목록 + */ + @Query("SELECT c FROM ContentJpaEntity c WHERE " + + "c.status IN ('PUBLISHED', 'SCHEDULED') " + + "ORDER BY c.createdAt DESC") + List findOngoingContents(); + + /** + * 매장 ID와 콘텐츠 타입으로 조회 + * @param storeId 매장 ID + * @param contentType 콘텐츠 타입 + * @return 콘텐츠 엔티티 목록 + */ + List findByStoreIdAndContentType(Long storeId, String contentType); + + /** + * 최근 생성된 콘텐츠 조회 (limit 적용) + * @param storeId 매장 ID + * @return 최근 콘텐츠 엔티티 목록 + */ + @Query("SELECT c FROM ContentJpaEntity c WHERE c.storeId = :storeId " + + "ORDER BY c.createdAt DESC") + List findRecentContentsByStoreId(@Param("storeId") Long storeId); +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/controller/ContentController.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/controller/ContentController.java new file mode 100644 index 0000000..4feb6b7 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/controller/ContentController.java @@ -0,0 +1,169 @@ +package com.won.smarketing.content.presentation.controller; + +import com.won.smarketing.common.dto.ApiResponse; +import com.won.smarketing.content.application.usecase.ContentQueryUseCase; +import com.won.smarketing.content.application.usecase.PosterContentUseCase; +import com.won.smarketing.content.application.usecase.SnsContentUseCase; +import com.won.smarketing.content.presentation.dto.*; +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 org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; +import java.util.List; + +/** + * 마케팅 콘텐츠 관리를 위한 REST API 컨트롤러 + * SNS 콘텐츠 생성, 포스터 생성, 콘텐츠 관리 기능 제공 + */ +@Tag(name = "마케팅 콘텐츠 관리", description = "AI 기반 마케팅 콘텐츠 생성 및 관리 API") +@RestController +@RequestMapping("/api/content") +@RequiredArgsConstructor +public class ContentController { + + private final SnsContentUseCase snsContentUseCase; + private final PosterContentUseCase posterContentUseCase; + private final ContentQueryUseCase contentQueryUseCase; + + /** + * SNS 게시물 생성 + * + * @param request SNS 콘텐츠 생성 요청 + * @return 생성된 SNS 콘텐츠 정보 + */ + @Operation(summary = "SNS 게시물 생성", description = "AI를 활용하여 SNS 게시물을 생성합니다.") + @PostMapping("/sns/generate") + public ResponseEntity> generateSnsContent(@Valid @RequestBody SnsContentCreateRequest request) { + SnsContentCreateResponse response = snsContentUseCase.generateSnsContent(request); + return ResponseEntity.ok(ApiResponse.success(response, "SNS 콘텐츠가 성공적으로 생성되었습니다.")); + } + + /** + * SNS 게시물 저장 + * + * @param request SNS 콘텐츠 저장 요청 + * @return 저장 성공 응답 + */ + @Operation(summary = "SNS 게시물 저장", description = "생성된 SNS 게시물을 저장합니다.") + @PostMapping("/sns/save") + public ResponseEntity> saveSnsContent(@Valid @RequestBody SnsContentSaveRequest request) { + snsContentUseCase.saveSnsContent(request); + return ResponseEntity.ok(ApiResponse.success(null, "SNS 콘텐츠가 성공적으로 저장되었습니다.")); + } + + /** + * 홍보 포스터 생성 + * + * @param request 포스터 콘텐츠 생성 요청 + * @return 생성된 포스터 콘텐츠 정보 + */ + @Operation(summary = "홍보 포스터 생성", description = "AI를 활용하여 홍보 포스터를 생성합니다.") + @PostMapping("/poster/generate") + public ResponseEntity> generatePosterContent(@Valid @RequestBody PosterContentCreateRequest request) { + PosterContentCreateResponse response = posterContentUseCase.generatePosterContent(request); + return ResponseEntity.ok(ApiResponse.success(response, "포스터 콘텐츠가 성공적으로 생성되었습니다.")); + } + + /** + * 홍보 포스터 저장 + * + * @param request 포스터 콘텐츠 저장 요청 + * @return 저장 성공 응답 + */ + @Operation(summary = "홍보 포스터 저장", description = "생성된 홍보 포스터를 저장합니다.") + @PostMapping("/poster/save") + public ResponseEntity> savePosterContent(@Valid @RequestBody PosterContentSaveRequest request) { + posterContentUseCase.savePosterContent(request); + return ResponseEntity.ok(ApiResponse.success(null, "포스터 콘텐츠가 성공적으로 저장되었습니다.")); + } + + /** + * 콘텐츠 수정 + * + * @param contentId 수정할 콘텐츠 ID + * @param request 콘텐츠 수정 요청 + * @return 수정된 콘텐츠 정보 + */ + @Operation(summary = "콘텐츠 수정", description = "기존 콘텐츠를 수정합니다.") + @PutMapping("/{contentId}") + public ResponseEntity> updateContent( + @Parameter(description = "콘텐츠 ID", required = true) + @PathVariable Long contentId, + @Valid @RequestBody ContentUpdateRequest request) { + ContentUpdateResponse response = contentQueryUseCase.updateContent(contentId, request); + return ResponseEntity.ok(ApiResponse.success(response, "콘텐츠가 성공적으로 수정되었습니다.")); + } + + /** + * 콘텐츠 목록 조회 + * + * @param contentType 콘텐츠 타입 필터 + * @param platform 플랫폼 필터 + * @param period 기간 필터 + * @param sortBy 정렬 기준 + * @return 콘텐츠 목록 + */ + @Operation(summary = "콘텐츠 목록 조회", description = "다양한 필터와 정렬 옵션으로 콘텐츠 목록을 조회합니다.") + @GetMapping + public ResponseEntity>> getContents( + @Parameter(description = "콘텐츠 타입") + @RequestParam(required = false) String contentType, + @Parameter(description = "플랫폼") + @RequestParam(required = false) String platform, + @Parameter(description = "기간") + @RequestParam(required = false) String period, + @Parameter(description = "정렬 기준") + @RequestParam(required = false) String sortBy) { + List response = contentQueryUseCase.getContents(contentType, platform, period, sortBy); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + /** + * 진행 중인 콘텐츠 목록 조회 + * + * @param period 기간 필터 + * @return 진행 중인 콘텐츠 목록 + */ + @Operation(summary = "진행 콘텐츠 조회", description = "현재 진행 중인 콘텐츠 목록을 조회합니다.") + @GetMapping("/ongoing") + public ResponseEntity>> getOngoingContents( + @Parameter(description = "기간") + @RequestParam(required = false) String period) { + List response = contentQueryUseCase.getOngoingContents(period); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + /** + * 콘텐츠 상세 조회 + * + * @param contentId 조회할 콘텐츠 ID + * @return 콘텐츠 상세 정보 + */ + @Operation(summary = "콘텐츠 상세 조회", description = "특정 콘텐츠의 상세 정보를 조회합니다.") + @GetMapping("/{contentId}") + public ResponseEntity> getContentDetail( + @Parameter(description = "콘텐츠 ID", required = true) + @PathVariable Long contentId) { + ContentDetailResponse response = contentQueryUseCase.getContentDetail(contentId); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + /** + * 콘텐츠 삭제 + * + * @param contentId 삭제할 콘텐츠 ID + * @return 삭제 성공 응답 + */ + @Operation(summary = "콘텐츠 삭제", description = "콘텐츠를 삭제합니다.") + @DeleteMapping("/{contentId}") + public ResponseEntity> deleteContent( + @Parameter(description = "콘텐츠 ID", required = true) + @PathVariable Long contentId) { + contentQueryUseCase.deleteContent(contentId); + return ResponseEntity.ok(ApiResponse.success(null, "콘텐츠가 성공적으로 삭제되었습니다.")); + } +} diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentDetailResponse.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentDetailResponse.java new file mode 100644 index 0000000..7cc6a52 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentDetailResponse.java @@ -0,0 +1,86 @@ +package com.won.smarketing.content.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; +import java.util.List; + +/** + * 콘텐츠 상세 응답 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "콘텐츠 상세 응답") +public class ContentDetailResponse { + + @Schema(description = "콘텐츠 ID", example = "1") + private Long contentId; + + @Schema(description = "콘텐츠 타입", example = "SNS_POST") + private String contentType; + + @Schema(description = "플랫폼", example = "INSTAGRAM") + private String platform; + + @Schema(description = "제목", example = "맛있는 신메뉴를 소개합니다!") + private String title; + + @Schema(description = "콘텐츠 내용") + private String content; + + @Schema(description = "해시태그 목록") + private List hashtags; + + @Schema(description = "이미지 URL 목록") + private List images; + + @Schema(description = "상태", example = "PUBLISHED") + private String status; + + @Schema(description = "홍보 시작일") + private LocalDateTime promotionStartDate; + + @Schema(description = "홍보 종료일") + private LocalDateTime promotionEndDate; + + @Schema(description = "생성 조건") + private CreationConditionsDto creationConditions; + + @Schema(description = "생성일시") + private LocalDateTime createdAt; + + @Schema(description = "수정일시") + private LocalDateTime updatedAt; + + /** + * 생성 조건 내부 DTO + */ + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "콘텐츠 생성 조건") + public static class CreationConditionsDto { + + @Schema(description = "톤앤매너", example = "친근함") + private String toneAndManner; + + @Schema(description = "프로모션 유형", example = "할인 정보") + private String promotionType; + + @Schema(description = "감정 강도", example = "보통") + private String emotionIntensity; + + @Schema(description = "홍보 대상", example = "메뉴") + private String targetAudience; + + @Schema(description = "이벤트명", example = "신메뉴 출시 이벤트") + private String eventName; + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentListRequest.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentListRequest.java new file mode 100644 index 0000000..8a35e35 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentListRequest.java @@ -0,0 +1,37 @@ +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 콘텐츠 목록 조회 요청 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "콘텐츠 목록 조회 요청") +public class ContentListRequest { + + @Schema(description = "콘텐츠 타입", example = "SNS_POST") + private String contentType; + + @Schema(description = "플랫폼", example = "INSTAGRAM") + private String platform; + + @Schema(description = "조회 기간", example = "7days") + private String period; + + @Schema(description = "정렬 기준", example = "createdAt") + private String sortBy; + + @Schema(description = "정렬 방향", example = "DESC") + private String sortDirection; + + @Schema(description = "페이지 번호", example = "0") + private Integer page; + + @Schema(description = "페이지 크기", example = "20") + private Integer size; +} diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentRegenerateRequest.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentRegenerateRequest.java new file mode 100644 index 0000000..47060a0 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentRegenerateRequest.java @@ -0,0 +1,33 @@ +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 콘텐츠 재생성 요청 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "콘텐츠 재생성 요청") +public class ContentRegenerateRequest { + + @Schema(description = "원본 콘텐츠 ID", example = "1", required = true) + @NotNull(message = "원본 콘텐츠 ID는 필수입니다") + private Long originalContentId; + + @Schema(description = "수정된 톤앤매너", example = "전문적") + private String toneAndManner; + + @Schema(description = "수정된 프로모션 유형", example = "신메뉴 알림") + private String promotionType; + + @Schema(description = "수정된 감정 강도", example = "열정적") + private String emotionIntensity; + + @Schema(description = "추가 요구사항", example = "더 감성적으로 작성해주세요") + private String additionalRequirements; +} diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentResponse.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentResponse.java new file mode 100644 index 0000000..964f4a2 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentResponse.java @@ -0,0 +1,364 @@ +// marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentResponse.java +package com.won.smarketing.content.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; +import java.util.List; + +/** + * 콘텐츠 응답 DTO + * 콘텐츠 목록 조회 시 사용되는 기본 응답 DTO + * + * 이 클래스는 콘텐츠의 핵심 정보만을 포함하여 목록 조회 시 성능을 최적화합니다. + * 상세 정보가 필요한 경우 ContentDetailResponse를 사용합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "콘텐츠 응답") +public class ContentResponse { + + // ==================== 기본 식별 정보 ==================== + + @Schema(description = "콘텐츠 ID", example = "1") + private Long contentId; + + @Schema(description = "콘텐츠 타입", example = "SNS_POST", + allowableValues = {"SNS_POST", "POSTER"}) + private String contentType; + + @Schema(description = "플랫폼", example = "INSTAGRAM", + allowableValues = {"INSTAGRAM", "NAVER_BLOG", "FACEBOOK", "KAKAO_STORY"}) + private String platform; + + // ==================== 콘텐츠 정보 ==================== + + @Schema(description = "제목", example = "맛있는 신메뉴를 소개합니다!") + private String title; + + @Schema(description = "콘텐츠 내용", example = "특별한 신메뉴가 출시되었습니다! 🍽️\n지금 바로 맛보세요!") + private String content; + + @Schema(description = "해시태그 목록", example = "[\"#맛집\", \"#신메뉴\", \"#추천\", \"#인스타그램\"]") + private List hashtags; + + @Schema(description = "이미지 URL 목록", + example = "[\"https://example.com/image1.jpg\", \"https://example.com/image2.jpg\"]") + private List images; + + // ==================== 상태 관리 ==================== + + @Schema(description = "상태", example = "PUBLISHED", + allowableValues = {"DRAFT", "PUBLISHED", "SCHEDULED", "ARCHIVED"}) + private String status; + + @Schema(description = "상태 표시명", example = "발행완료") + private String statusDisplay; + + // ==================== 홍보 기간 ==================== + + @Schema(description = "홍보 시작일", example = "2024-01-15T09:00:00") + private LocalDateTime promotionStartDate; + + @Schema(description = "홍보 종료일", example = "2024-01-22T23:59:59") + private LocalDateTime promotionEndDate; + + // ==================== 시간 정보 ==================== + + @Schema(description = "생성일시", example = "2024-01-15T10:30:00") + private LocalDateTime createdAt; + + @Schema(description = "수정일시", example = "2024-01-15T14:20:00") + private LocalDateTime updatedAt; + + // ==================== 계산된 필드들 ==================== + + @Schema(description = "홍보 진행 상태", example = "ONGOING", + allowableValues = {"UPCOMING", "ONGOING", "COMPLETED"}) + private String promotionStatus; + + @Schema(description = "남은 홍보 일수", example = "5") + private Long remainingDays; + + @Schema(description = "홍보 진행률 (%)", example = "60.5") + private Double progressPercentage; + + @Schema(description = "콘텐츠 요약 (첫 50자)", example = "특별한 신메뉴가 출시되었습니다! 지금 바로 맛보세요...") + private String contentSummary; + + @Schema(description = "이미지 개수", example = "3") + private Integer imageCount; + + @Schema(description = "해시태그 개수", example = "8") + private Integer hashtagCount; + + @Schema(description = "조회수", example = "8") + private Integer viewCount; + + // ==================== 비즈니스 메서드 ==================== + + /** + * 콘텐츠 요약 생성 + * 콘텐츠가 길 경우 첫 50자만 표시하고 "..." 추가 + * + * @param content 원본 콘텐츠 + * @param maxLength 최대 길이 + * @return 요약된 콘텐츠 + */ + public static String createContentSummary(String content, int maxLength) { + if (content == null || content.length() <= maxLength) { + return content; + } + return content.substring(0, maxLength) + "..."; + } + + /** + * 홍보 상태 계산 + * 현재 시간과 홍보 기간을 비교하여 상태 결정 + * + * @param startDate 홍보 시작일 + * @param endDate 홍보 종료일 + * @return 홍보 상태 + */ + public static String calculatePromotionStatus(LocalDateTime startDate, LocalDateTime endDate) { + if (startDate == null || endDate == null) { + return "UNKNOWN"; + } + + LocalDateTime now = LocalDateTime.now(); + + if (now.isBefore(startDate)) { + return "UPCOMING"; // 홍보 예정 + } else if (now.isAfter(endDate)) { + return "COMPLETED"; // 홍보 완료 + } else { + return "ONGOING"; // 홍보 진행중 + } + } + + /** + * 남은 일수 계산 + * 홍보 종료일까지 남은 일수 계산 + * + * @param endDate 홍보 종료일 + * @return 남은 일수 (음수면 0 반환) + */ + public static Long calculateRemainingDays(LocalDateTime endDate) { + if (endDate == null) { + return 0L; + } + + LocalDateTime now = LocalDateTime.now(); + if (now.isAfter(endDate)) { + return 0L; + } + + return java.time.Duration.between(now, endDate).toDays(); + } + + /** + * 진행률 계산 + * 홍보 기간 대비 진행률 계산 (0-100%) + * + * @param startDate 홍보 시작일 + * @param endDate 홍보 종료일 + * @return 진행률 (0-100%) + */ + public static Double calculateProgressPercentage(LocalDateTime startDate, LocalDateTime endDate) { + if (startDate == null || endDate == null) { + return 0.0; + } + + LocalDateTime now = LocalDateTime.now(); + + if (now.isBefore(startDate)) { + return 0.0; // 아직 시작 안함 + } else if (now.isAfter(endDate)) { + return 100.0; // 완료 + } + + long totalDuration = java.time.Duration.between(startDate, endDate).toHours(); + long elapsedDuration = java.time.Duration.between(startDate, now).toHours(); + + if (totalDuration == 0) { + return 100.0; + } + + return (double) elapsedDuration / totalDuration * 100.0; + } + + /** + * 상태 표시명 변환 + * 영문 상태를 한글로 변환 + * + * @param status 영문 상태 + * @return 한글 상태명 + */ + public static String getStatusDisplay(String status) { + if (status == null) { + return "알 수 없음"; + } + + switch (status) { + case "DRAFT": + return "임시저장"; + case "PUBLISHED": + return "발행완료"; + case "SCHEDULED": + return "예약발행"; + case "ARCHIVED": + return "보관됨"; + default: + return status; + } + } + + // ==================== Builder 확장 메서드 ==================== + + /** + * 도메인 엔티티에서 ContentResponse 생성 + * 계산된 필드들을 자동으로 설정 + * + * @param content 콘텐츠 도메인 엔티티 + * @return ContentResponse + */ + public static ContentResponse fromDomain(com.won.smarketing.content.domain.model.Content content) { + ContentResponseBuilder builder = ContentResponse.builder() + .contentId(content.getId()) + .contentType(content.getContentType().name()) + .platform(content.getPlatform().name()) + .title(content.getTitle()) + .content(content.getContent()) + .hashtags(content.getHashtags()) + .images(content.getImages()) + .status(content.getStatus().name()) + .statusDisplay(getStatusDisplay(content.getStatus().name())) + .promotionStartDate(content.getPromotionStartDate()) + .promotionEndDate(content.getPromotionEndDate()) + .createdAt(content.getCreatedAt()) + .updatedAt(content.getUpdatedAt()); + + // 계산된 필드들 설정 + builder.contentSummary(createContentSummary(content.getContent(), 50)); + builder.imageCount(content.getImages() != null ? content.getImages().size() : 0); + builder.hashtagCount(content.getHashtags() != null ? content.getHashtags().size() : 0); + + // 홍보 관련 계산 필드들 + builder.promotionStatus(calculatePromotionStatus( + content.getPromotionStartDate(), + content.getPromotionEndDate())); + builder.remainingDays(calculateRemainingDays(content.getPromotionEndDate())); + builder.progressPercentage(calculateProgressPercentage( + content.getPromotionStartDate(), + content.getPromotionEndDate())); + + return builder.build(); + } + + // ==================== 유틸리티 메서드 ==================== + + /** + * 콘텐츠가 현재 활성 상태인지 확인 + * + * @return 홍보 기간 내이고 발행 상태면 true + */ + public boolean isActive() { + return "PUBLISHED".equals(status) && "ONGOING".equals(promotionStatus); + } + + /** + * 콘텐츠 수정 가능 여부 확인 + * + * @return 임시저장 상태이거나 예약발행 상태면 true + */ + public boolean isEditable() { + return "DRAFT".equals(status) || "SCHEDULED".equals(status); + } + + /** + * 이미지가 있는 콘텐츠인지 확인 + * + * @return 이미지가 1개 이상 있으면 true + */ + public boolean hasImages() { + return images != null && !images.isEmpty(); + } + + /** + * 해시태그가 있는 콘텐츠인지 확인 + * + * @return 해시태그가 1개 이상 있으면 true + */ + public boolean hasHashtags() { + return hashtags != null && !hashtags.isEmpty(); + } + + /** + * 디버깅용 toString (간소화된 정보만) + */ + @Override + public String toString() { + return "ContentResponse{" + + "contentId=" + contentId + + ", contentType='" + contentType + '\'' + + ", platform='" + platform + '\'' + + ", title='" + title + '\'' + + ", status='" + status + '\'' + + ", promotionStatus='" + promotionStatus + '\'' + + ", createdAt=" + createdAt + + '}'; + } +} + +/* +==================== 사용 예시 ==================== + +// 1. 도메인 엔티티에서 DTO 생성 +Content domainContent = contentRepository.findById(contentId); +ContentResponse response = ContentResponse.fromDomain(domainContent); + +// 2. 수동으로 빌더 사용 +ContentResponse response = ContentResponse.builder() + .contentId(1L) + .contentType("SNS_POST") + .platform("INSTAGRAM") + .title("맛있는 신메뉴") + .content("특별한 신메뉴가 출시되었습니다!") + .status("PUBLISHED") + .build(); + +// 3. 비즈니스 로직 활용 +boolean canEdit = response.isEditable(); +boolean isLive = response.isActive(); +String summary = response.getContentSummary(); + +==================== JSON 응답 예시 ==================== + +{ + "contentId": 1, + "contentType": "SNS_POST", + "platform": "INSTAGRAM", + "title": "맛있는 신메뉴를 소개합니다!", + "content": "특별한 신메뉴가 출시되었습니다! 🍽️\n지금 바로 맛보세요!", + "hashtags": ["#맛집", "#신메뉴", "#추천", "#인스타그램"], + "images": ["https://example.com/image1.jpg"], + "status": "PUBLISHED", + "statusDisplay": "발행완료", + "promotionStartDate": "2024-01-15T09:00:00", + "promotionEndDate": "2024-01-22T23:59:59", + "createdAt": "2024-01-15T10:30:00", + "updatedAt": "2024-01-15T14:20:00", + "promotionStatus": "ONGOING", + "remainingDays": 5, + "progressPercentage": 60.5, + "contentSummary": "특별한 신메뉴가 출시되었습니다! 지금 바로 맛보세요...", + "imageCount": 1, + "hashtagCount": 4 +} +*/ \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentStatisticsResponse.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentStatisticsResponse.java new file mode 100644 index 0000000..fed7dfa --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentStatisticsResponse.java @@ -0,0 +1,41 @@ +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +/** + * 콘텐츠 통계 응답 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "콘텐츠 통계 응답") +public class ContentStatisticsResponse { + + @Schema(description = "총 콘텐츠 수", example = "150") + private Long totalContents; + + @Schema(description = "이번 달 생성된 콘텐츠 수", example = "25") + private Long thisMonthContents; + + @Schema(description = "발행된 콘텐츠 수", example = "120") + private Long publishedContents; + + @Schema(description = "임시저장된 콘텐츠 수", example = "30") + private Long draftContents; + + @Schema(description = "콘텐츠 타입별 통계") + private Map contentTypeStats; + + @Schema(description = "플랫폼별 통계") + private Map platformStats; + + @Schema(description = "월별 생성 통계 (최근 6개월)") + private Map monthlyStats; +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentUpdateRequest.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentUpdateRequest.java new file mode 100644 index 0000000..d550f0f --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentUpdateRequest.java @@ -0,0 +1,33 @@ +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 콘텐츠 수정 요청 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "콘텐츠 수정 요청") +public class ContentUpdateRequest { + + @Schema(description = "제목", example = "수정된 제목") + private String title; + + @Schema(description = "콘텐츠 내용") + private String content; + + @Schema(description = "홍보 시작일") + private LocalDateTime promotionStartDate; + + @Schema(description = "홍보 종료일") + private LocalDateTime promotionEndDate; + + @Schema(description = "상태", example = "PUBLISHED") + private String status; +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentUpdateResponse.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentUpdateResponse.java new file mode 100644 index 0000000..3296ee2 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/ContentUpdateResponse.java @@ -0,0 +1,35 @@ +package com.won.smarketing.content.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; + +/** + * 콘텐츠 수정 응답 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "콘텐츠 수정 응답") +public class ContentUpdateResponse { + + @Schema(description = "콘텐츠 ID", example = "1") + private Long contentId; + + @Schema(description = "수정된 제목", example = "수정된 제목") + private String title; + + @Schema(description = "수정된 콘텐츠 내용") + private String content; + + @Schema(description = "상태", example = "PUBLISHED") + private String status; + + @Schema(description = "수정일시") + private LocalDateTime updatedAt; +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/CreationConditionsDto.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/CreationConditionsDto.java new file mode 100644 index 0000000..403cdfa --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/CreationConditionsDto.java @@ -0,0 +1,45 @@ +// marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/CreationConditionsDto.java +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +/** + * 콘텐츠 생성 조건 DTO + */ +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "콘텐츠 생성 조건") +public class CreationConditionsDto { + + @Schema(description = "카테고리", example = "음식") + private String category; + + @Schema(description = "생성 요구사항", example = "젊은 고객층을 타겟으로 한 재미있는 콘텐츠") + private String requirement; + + @Schema(description = "톤앤매너", example = "친근하고 활발한") + private String toneAndManner; + + @Schema(description = "감정 강도", example = "보통") + private String emotionIntensity; + + @Schema(description = "이벤트명", example = "신메뉴 출시 이벤트") + private String eventName; + + @Schema(description = "시작일") + private LocalDate startDate; + + @Schema(description = "종료일") + private LocalDate endDate; + + @Schema(description = "사진 스타일", example = "모던하고 깔끔한") + private String photoStyle; +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/OngoingContentResponse.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/OngoingContentResponse.java new file mode 100644 index 0000000..047cb2d --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/OngoingContentResponse.java @@ -0,0 +1,47 @@ +package com.won.smarketing.content.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; + +/** + * 진행 중인 콘텐츠 응답 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "진행 중인 콘텐츠 응답") +public class OngoingContentResponse { + + @Schema(description = "콘텐츠 ID", example = "1") + private Long contentId; + + @Schema(description = "콘텐츠 타입", example = "SNS_POST") + private String contentType; + + @Schema(description = "플랫폼", example = "INSTAGRAM") + private String platform; + + @Schema(description = "제목", example = "진행 중인 이벤트") + private String title; + + @Schema(description = "상태", example = "PUBLISHED") + private String status; + + @Schema(description = "홍보 시작일") + private LocalDateTime promotionStartDate; + + @Schema(description = "홍보 종료일") + private LocalDateTime promotionEndDate; + + @Schema(description = "남은 일수", example = "5") + private Long remainingDays; + + @Schema(description = "진행률 (%)", example = "60.5") + private Double progressPercentage; +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateRequest.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateRequest.java new file mode 100644 index 0000000..3ea3a15 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateRequest.java @@ -0,0 +1,79 @@ +// smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateRequest.java +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 포스터 콘텐츠 생성 요청 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "포스터 콘텐츠 생성 요청") +public class PosterContentCreateRequest { + + @Schema(description = "매장 ID", example = "1", required = true) + @NotNull(message = "매장 ID는 필수입니다") + private Long storeId; + + @Schema(description = "제목", example = "특별 이벤트 안내") + private String title; + + @Schema(description = "홍보 대상", example = "메뉴", required = true) + @NotBlank(message = "홍보 대상은 필수입니다") + private String targetAudience; + + @Schema(description = "홍보 시작일", required = true) + @NotNull(message = "홍보 시작일은 필수입니다") + private LocalDateTime promotionStartDate; + + @Schema(description = "홍보 종료일", required = true) + @NotNull(message = "홍보 종료일은 필수입니다") + private LocalDateTime promotionEndDate; + + @Schema(description = "이벤트명 (이벤트 홍보시)", example = "신메뉴 출시 이벤트") + private String eventName; + + @Schema(description = "이미지 스타일", example = "모던") + private String imageStyle; + + @Schema(description = "프로모션 유형", example = "할인 정보") + private String promotionType; + + @Schema(description = "감정 강도", example = "보통") + private String emotionIntensity; + + @Schema(description = "업로드된 이미지 URL 목록", required = true) + @NotNull(message = "이미지는 1개 이상 필수입니다") + @Size(min = 1, message = "이미지는 1개 이상 업로드해야 합니다") + private List images; + + // CreationConditions에 필요한 필드들 + @Schema(description = "콘텐츠 카테고리", example = "이벤트") + private String category; + + @Schema(description = "구체적인 요구사항", example = "신메뉴 출시 이벤트 포스터를 만들어주세요") + private String requirement; + + @Schema(description = "톤앤매너", example = "전문적") + private String toneAndManner; + + @Schema(description = "이벤트 시작일", example = "2024-01-15") + private LocalDate startDate; + + @Schema(description = "이벤트 종료일", example = "2024-01-31") + private LocalDate endDate; + + @Schema(description = "사진 스타일", example = "밝고 화사한") + private String photoStyle; +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateResponse.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateResponse.java new file mode 100644 index 0000000..0c02b68 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentCreateResponse.java @@ -0,0 +1,49 @@ +package com.won.smarketing.content.presentation.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; +import java.util.Map; + +/** + * 포스터 콘텐츠 생성 응답 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "포스터 콘텐츠 생성 응답") +public class PosterContentCreateResponse { + + @Schema(description = "콘텐츠 ID", example = "1") + private Long contentId; + + @Schema(description = "생성된 포스터 제목", example = "특별 이벤트 안내") + private String title; + + @Schema(description = "생성된 포스터 텍스트 내용") + private String content; + + @Schema(description = "생성된 포스터 타입") + private String contentType; + + @Schema(description = "포스터 이미지 URL") + private String posterImage; + + @Schema(description = "원본 이미지 URL 목록") + private List originalImages; + + @Schema(description = "이미지 스타일", example = "모던") + private String imageStyle; + + @Schema(description = "생성 상태", example = "DRAFT") + private String status; + + @Schema(description = "포스터사이즈", example = "800x600") + private Map posterSizes; + +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentSaveRequest.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentSaveRequest.java new file mode 100644 index 0000000..5335d11 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentSaveRequest.java @@ -0,0 +1,66 @@ +// smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/PosterContentSaveRequest.java +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.util.List; + +/** + * 포스터 콘텐츠 저장 요청 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "포스터 콘텐츠 저장 요청") +public class PosterContentSaveRequest { + + @Schema(description = "콘텐츠 ID", example = "1", required = true) + @NotNull(message = "콘텐츠 ID는 필수입니다") + private Long contentId; + + @Schema(description = "매장 ID", example = "1", required = true) + @NotNull(message = "매장 ID는 필수입니다") + private Long storeId; + + @Schema(description = "제목", example = "특별 이벤트 안내") + private String title; + + @Schema(description = "콘텐츠 내용") + private String content; + + @Schema(description = "선택된 포스터 이미지 URL") + private List images; + + @Schema(description = "발행 상태", example = "PUBLISHED") + private String status; + + // CreationConditions에 필요한 필드들 + @Schema(description = "콘텐츠 카테고리", example = "이벤트") + private String category; + + @Schema(description = "구체적인 요구사항", example = "신메뉴 출시 이벤트 포스터를 만들어주세요") + private String requirement; + + @Schema(description = "톤앤매너", example = "전문적") + private String toneAndManner; + + @Schema(description = "감정 강도", example = "보통") + private String emotionIntensity; + + @Schema(description = "이벤트명", example = "신메뉴 출시 이벤트") + private String eventName; + + @Schema(description = "이벤트 시작일", example = "2024-01-15") + private LocalDate startDate; + + @Schema(description = "이벤트 종료일", example = "2024-01-31") + private LocalDate endDate; + + @Schema(description = "사진 스타일", example = "밝고 화사한") + private String photoStyle; +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentCreateRequest.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentCreateRequest.java new file mode 100644 index 0000000..70235b5 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentCreateRequest.java @@ -0,0 +1,160 @@ +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.util.List; + +/** + * SNS 콘텐츠 생성 요청 DTO + * + * AI 기반 SNS 콘텐츠 생성을 위한 요청 정보를 담고 있습니다. + * 사용자가 입력한 생성 조건을 바탕으로 AI가 적절한 SNS 콘텐츠를 생성합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "SNS 콘텐츠 생성 요청") +public class SnsContentCreateRequest { + + // ==================== 기본 정보 ==================== + + @Schema(description = "매장 ID", example = "1", required = true) + @NotNull(message = "매장 ID는 필수입니다") + private Long storeId; + + @Schema(description = "대상 플랫폼", + example = "INSTAGRAM", + allowableValues = {"INSTAGRAM", "NAVER_BLOG", "FACEBOOK", "KAKAO_STORY"}, + required = true) + @NotBlank(message = "플랫폼은 필수입니다") + private String platform; + + @Schema(description = "콘텐츠 제목", example = "1", required = true) + @NotNull(message = "콘텐츠 제목은 필수입니다") + private String title; + + // ==================== 콘텐츠 생성 조건 ==================== + + @Schema(description = "콘텐츠 카테고리", + example = "메뉴소개", + allowableValues = {"메뉴소개", "이벤트", "일상", "인테리어", "고객후기", "기타"}) + private String category; + + @Schema(description = "구체적인 요구사항 또는 홍보하고 싶은 내용", + example = "새로 출시된 시그니처 버거를 홍보하고 싶어요") + @Size(max = 500, message = "요구사항은 500자 이하로 입력해주세요") + private String requirement; + + @Schema(description = "톤앤매너", + example = "친근함", + allowableValues = {"친근함", "전문적", "유머러스", "감성적", "트렌디"}) + private String toneAndManner; + + @Schema(description = "감정 강도", + example = "보통", + allowableValues = {"약함", "보통", "강함"}) + private String emotionIntensity; + + // ==================== 이벤트 정보 ==================== + + @Schema(description = "이벤트명 (이벤트 콘텐츠인 경우)", + example = "신메뉴 출시 이벤트") + @Size(max = 200, message = "이벤트명은 200자 이하로 입력해주세요") + private String eventName; + + @Schema(description = "이벤트 시작일 (이벤트 콘텐츠인 경우)", + example = "2024-01-15") + private LocalDate startDate; + + @Schema(description = "이벤트 종료일 (이벤트 콘텐츠인 경우)", + example = "2024-01-31") + private LocalDate endDate; + + // ==================== 미디어 정보 ==================== + + @Schema(description = "업로드된 이미지 파일 경로 목록") + private List images; + + @Schema(description = "사진 스타일 선호도", + example = "밝고 화사한", + allowableValues = {"밝고 화사한", "차분하고 세련된", "빈티지한", "모던한", "자연스러운"}) + private String photoStyle; + + // ==================== 추가 옵션 ==================== + + @Schema(description = "해시태그 포함 여부", example = "true") + @Builder.Default + private Boolean includeHashtags = true; + + @Schema(description = "이모지 포함 여부", example = "true") + @Builder.Default + private Boolean includeEmojis = true; + + @Schema(description = "콜투액션 포함 여부 (좋아요, 팔로우 요청 등)", example = "true") + @Builder.Default + private Boolean includeCallToAction = true; + + @Schema(description = "매장 위치 정보 포함 여부", example = "false") + @Builder.Default + private Boolean includeLocation = false; + + // ==================== 플랫폼별 옵션 ==================== + + @Schema(description = "인스타그램 스토리용 여부 (Instagram인 경우)", example = "false") + @Builder.Default + private Boolean forInstagramStory = false; + + @Schema(description = "네이버 블로그 포스팅용 여부 (Naver Blog인 경우)", example = "false") + @Builder.Default + private Boolean forNaverBlogPost = false; + + // ==================== AI 생성 옵션 ==================== + + @Schema(description = "대안 제목 생성 개수", example = "3") + @Builder.Default + private Integer alternativeTitleCount = 3; + + @Schema(description = "대안 해시태그 세트 생성 개수", example = "2") + @Builder.Default + private Integer alternativeHashtagSetCount = 2; + + @Schema(description = "AI 모델 버전 지정 (없으면 기본값 사용)", example = "gpt-4-turbo") + private String preferredAiModel; + + // ==================== 검증 메서드 ==================== + + /** + * 이벤트 날짜 유효성 검증 + * 시작일이 종료일보다 이후인지 확인 + */ + public boolean isValidEventDates() { + if (startDate != null && endDate != null) { + return !startDate.isAfter(endDate); + } + return true; + } + + /** + * 플랫폼별 필수 조건 검증 + */ + public boolean isValidForPlatform() { + if ("INSTAGRAM".equals(platform)) { + // 인스타그램은 이미지가 권장됨 + return images != null && !images.isEmpty(); + } + if ("NAVER_BLOG".equals(platform)) { + // 네이버 블로그는 상세한 내용이 필요 + return requirement != null && requirement.length() >= 20; + } + return true; + } +} diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentCreateResponse.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentCreateResponse.java new file mode 100644 index 0000000..0acf9ec --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentCreateResponse.java @@ -0,0 +1,383 @@ +package com.won.smarketing.content.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; +import java.util.List; + +/** + * SNS 콘텐츠 생성 응답 DTO + * + * AI를 통해 SNS 콘텐츠를 생성한 후 클라이언트에게 반환되는 응답 정보입니다. + * 생성된 콘텐츠의 기본 정보와 함께 사용자가 추가 편집할 수 있는 정보를 포함합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "SNS 콘텐츠 생성 응답") +public class SnsContentCreateResponse { + + // ==================== 기본 식별 정보 ==================== + + @Schema(description = "생성된 콘텐츠 ID", example = "1") + private Long contentId; + + @Schema(description = "콘텐츠 타입", example = "SNS_POST") + private String contentType; + + @Schema(description = "대상 플랫폼", example = "INSTAGRAM", + allowableValues = {"INSTAGRAM", "NAVER_BLOG", "FACEBOOK", "KAKAO_STORY"}) + private String platform; + + // ==================== AI 생성 콘텐츠 ==================== + + @Schema(description = "AI가 생성한 콘텐츠 제목", + example = "맛있는 신메뉴를 소개합니다! ✨") + private String title; + + @Schema(description = "AI가 생성한 콘텐츠 내용", + example = "안녕하세요! 😊\n\n특별한 신메뉴가 출시되었습니다!\n진짜 맛있어서 꼭 한번 드셔보세요 🍽️\n\n매장에서 기다리고 있을게요! 💫") + private String content; + + @Schema(description = "AI가 생성한 해시태그 목록", + example = "[\"맛집\", \"신메뉴\", \"추천\", \"인스타그램\", \"일상\", \"좋아요\", \"팔로우\", \"맛있어요\"]") + private List hashtags; + + // ==================== 플랫폼별 최적화 정보 ==================== + + @Schema(description = "플랫폼별 최적화된 콘텐츠 길이", example = "280") + private Integer contentLength; + + @Schema(description = "플랫폼별 권장 해시태그 개수", example = "8") + private Integer recommendedHashtagCount; + + @Schema(description = "플랫폼별 최대 해시태그 개수", example = "15") + private Integer maxHashtagCount; + + // ==================== 생성 조건 정보 ==================== + + @Schema(description = "콘텐츠 생성에 사용된 조건들") + private GenerationConditionsDto generationConditions; + + // ==================== 상태 및 메타데이터 ==================== + + @Schema(description = "생성 상태", example = "DRAFT", + allowableValues = {"DRAFT", "PUBLISHED", "SCHEDULED"}) + private String status; + + @Schema(description = "생성 일시", example = "2024-01-15T10:30:00") + private LocalDateTime createdAt; + + @Schema(description = "AI 모델 버전", example = "gpt-4-turbo") + private String aiModelVersion; + + @Schema(description = "생성 시간 (초)", example = "3.5") + private Double generationTimeSeconds; + + // ==================== 추가 정보 ==================== + + @Schema(description = "업로드된 원본 이미지 URL 목록") + private List originalImages; + + @Schema(description = "콘텐츠 품질 점수 (1-100)", example = "85") + private Integer qualityScore; + + @Schema(description = "예상 참여율 (%)", example = "12.5") + private Double expectedEngagementRate; + + @Schema(description = "콘텐츠 카테고리", example = "음식/메뉴소개") + private String category; + + @Schema(description = "보정된 이미지 URL 목록") + private List fixedImages; + + // ==================== 편집 가능 여부 ==================== + + @Schema(description = "제목 편집 가능 여부", example = "true") + @Builder.Default + private Boolean titleEditable = true; + + @Schema(description = "내용 편집 가능 여부", example = "true") + @Builder.Default + private Boolean contentEditable = true; + + @Schema(description = "해시태그 편집 가능 여부", example = "true") + @Builder.Default + private Boolean hashtagsEditable = true; + + // ==================== 대안 콘텐츠 ==================== + + @Schema(description = "대안 제목 목록 (사용자 선택용)") + private List alternativeTitles; + + @Schema(description = "대안 해시태그 세트 목록") + private List> alternativeHashtagSets; + + // ==================== 내부 DTO 클래스 ==================== + + /** + * 콘텐츠 생성 조건 DTO + */ + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "콘텐츠 생성 조건") + public static class GenerationConditionsDto { + + @Schema(description = "홍보 대상", example = "메뉴") + private String targetAudience; + + @Schema(description = "이벤트명", example = "신메뉴 출시 이벤트") + private String eventName; + + @Schema(description = "톤앤매너", example = "친근함") + private String toneAndManner; + + @Schema(description = "프로모션 유형", example = "할인 정보") + private String promotionType; + + @Schema(description = "감정 강도", example = "보통") + private String emotionIntensity; + + @Schema(description = "홍보 시작일", example = "2024-01-15T09:00:00") + private LocalDateTime promotionStartDate; + + @Schema(description = "홍보 종료일", example = "2024-01-22T23:59:59") + private LocalDateTime promotionEndDate; + } + + // ==================== 비즈니스 메서드 ==================== + + /** + * 플랫폼별 콘텐츠 최적화 여부 확인 + * + * @return 콘텐츠가 플랫폼 권장 사항을 만족하면 true + */ + public boolean isOptimizedForPlatform() { + if (content == null || hashtags == null) { + return false; + } + + // 플랫폼별 최적화 기준 + switch (platform.toUpperCase()) { + case "INSTAGRAM": + return content.length() <= 2200 && + hashtags.size() <= 15 && + hashtags.size() >= 5; + case "NAVER_BLOG": + return content.length() >= 300 && + hashtags.size() <= 10 && + hashtags.size() >= 3; + case "FACEBOOK": + return content.length() <= 500 && + hashtags.size() <= 5; + default: + return true; + } + } + + /** + * 고품질 콘텐츠 여부 확인 + * + * @return 품질 점수가 80점 이상이면 true + */ + public boolean isHighQuality() { + return qualityScore != null && qualityScore >= 80; + } + + /** + * 참여율 예상 등급 반환 + * + * @return 예상 참여율 등급 (HIGH, MEDIUM, LOW) + */ + public String getExpectedEngagementLevel() { + if (expectedEngagementRate == null) { + return "UNKNOWN"; + } + + if (expectedEngagementRate >= 15.0) { + return "HIGH"; + } else if (expectedEngagementRate >= 8.0) { + return "MEDIUM"; + } else { + return "LOW"; + } + } + + /** + * 해시태그를 문자열로 변환 (# 포함) + * + * @return #으로 시작하는 해시태그 문자열 + */ + public String getHashtagsAsString() { + if (hashtags == null || hashtags.isEmpty()) { + return ""; + } + + return hashtags.stream() + .map(tag -> "#" + tag) + .reduce((a, b) -> a + " " + b) + .orElse(""); + } + + /** + * 콘텐츠 요약 생성 + * + * @param maxLength 최대 길이 + * @return 요약된 콘텐츠 + */ + public String getContentSummary(int maxLength) { + if (content == null || content.length() <= maxLength) { + return content; + } + return content.substring(0, maxLength) + "..."; + } + + /** + * 플랫폼별 최적화 제안사항 반환 + * + * @return 최적화 제안사항 목록 + */ + public List getOptimizationSuggestions() { + List suggestions = new java.util.ArrayList<>(); + + if (!isOptimizedForPlatform()) { + switch (platform.toUpperCase()) { + case "INSTAGRAM": + if (content != null && content.length() > 2200) { + suggestions.add("콘텐츠 길이를 2200자 이하로 줄여주세요."); + } + if (hashtags != null && hashtags.size() > 15) { + suggestions.add("해시태그를 15개 이하로 줄여주세요."); + } + if (hashtags != null && hashtags.size() < 5) { + suggestions.add("해시태그를 5개 이상 추가해주세요."); + } + break; + case "NAVER_BLOG": + if (content != null && content.length() < 300) { + suggestions.add("블로그 포스팅을 위해 내용을 300자 이상으로 늘려주세요."); + } + if (hashtags != null && hashtags.size() > 10) { + suggestions.add("네이버 블로그는 해시태그를 10개 이하로 사용하는 것이 좋습니다."); + } + break; + case "FACEBOOK": + if (content != null && content.length() > 500) { + suggestions.add("페이스북에서는 500자 이하의 짧은 글이 더 효과적입니다."); + } + break; + } + } + + return suggestions; + } + + // ==================== 팩토리 메서드 ==================== + + /** + * 도메인 엔티티에서 SnsContentCreateResponse 생성 + * + * @param content 콘텐츠 도메인 엔티티 + * @param aiMetadata AI 생성 메타데이터 + * @return SnsContentCreateResponse + */ + public static SnsContentCreateResponse fromDomain( + com.won.smarketing.content.domain.model.Content content, + AiGenerationMetadata aiMetadata) { + + SnsContentCreateResponseBuilder builder = SnsContentCreateResponse.builder() + .contentId(content.getId()) + .contentType(content.getContentType().name()) + .platform(content.getPlatform().name()) + .title(content.getTitle()) + .content(content.getContent()) + .hashtags(content.getHashtags()) + .status(content.getStatus().name()) + .createdAt(content.getCreatedAt()) + .originalImages(content.getImages()); + + // 생성 조건 정보 설정 + if (content.getCreationConditions() != null) { + builder.generationConditions(GenerationConditionsDto.builder() + //.targetAudience(content.getCreationConditions().getTargetAudience()) + .eventName(content.getCreationConditions().getEventName()) + .toneAndManner(content.getCreationConditions().getToneAndManner()) + .promotionType(content.getCreationConditions().getPromotionType()) + .emotionIntensity(content.getCreationConditions().getEmotionIntensity()) + .promotionStartDate(content.getPromotionStartDate()) + .promotionEndDate(content.getPromotionEndDate()) + .build()); + } + + // AI 메타데이터 설정 + if (aiMetadata != null) { + builder.aiModelVersion(aiMetadata.getModelVersion()) + .generationTimeSeconds(aiMetadata.getGenerationTime()) + .qualityScore(aiMetadata.getQualityScore()) + .expectedEngagementRate(aiMetadata.getExpectedEngagementRate()) + .alternativeTitles(aiMetadata.getAlternativeTitles()) + .alternativeHashtagSets(aiMetadata.getAlternativeHashtagSets()); + } + + // 플랫폼별 최적화 정보 설정 + SnsContentCreateResponse response = builder.build(); + response.setContentLength(response.getContent() != null ? response.getContent().length() : 0); + response.setRecommendedHashtagCount(getRecommendedHashtagCount(content.getPlatform().name())); + response.setMaxHashtagCount(getMaxHashtagCount(content.getPlatform().name())); + + return response; + } + + /** + * 플랫폼별 권장 해시태그 개수 반환 + */ + private static Integer getRecommendedHashtagCount(String platform) { + switch (platform.toUpperCase()) { + case "INSTAGRAM": return 8; + case "NAVER_BLOG": return 5; + case "FACEBOOK": return 3; + case "KAKAO_STORY": return 5; + default: return 5; + } + } + + /** + * 플랫폼별 최대 해시태그 개수 반환 + */ + private static Integer getMaxHashtagCount(String platform) { + switch (platform.toUpperCase()) { + case "INSTAGRAM": return 15; + case "NAVER_BLOG": return 10; + case "FACEBOOK": return 5; + case "KAKAO_STORY": return 8; + default: return 10; + } + } + + // ==================== AI 생성 메타데이터 DTO ==================== + + /** + * AI 생성 메타데이터 + * AI 생성 과정에서 나온 부가 정보들 + */ + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + public static class AiGenerationMetadata { + private String modelVersion; + private Double generationTime; + private Integer qualityScore; + private Double expectedEngagementRate; + private List alternativeTitles; + private List> alternativeHashtagSets; + private String category; + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentSaveRequest.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentSaveRequest.java new file mode 100644 index 0000000..9adb6c8 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentSaveRequest.java @@ -0,0 +1,79 @@ +// smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/presentation/dto/SnsContentSaveRequest.java +package com.won.smarketing.content.presentation.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +/** + * SNS 콘텐츠 저장 요청 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "SNS 콘텐츠 저장 요청") +public class SnsContentSaveRequest { + + @Schema(description = "콘텐츠 ID", example = "1", required = true) + @NotNull(message = "콘텐츠 ID는 필수입니다") + private Long contentId; + + @Schema(description = "매장 ID", example = "1", required = true) + @NotNull(message = "매장 ID는 필수입니다") + private Long storeId; + + @Schema(description = "플랫폼", example = "INSTAGRAM", required = true) + @NotBlank(message = "플랫폼은 필수입니다") + private String platform; + + @Schema(description = "제목", example = "맛있는 신메뉴를 소개합니다!") + private String title; + + @Schema(description = "콘텐츠 내용") + private String content; + + @Schema(description = "해시태그 목록") + private List hashtags; + + @Schema(description = "이미지 URL 목록") + private List images; + + @Schema(description = "최종 제목", example = "맛있는 신메뉴를 소개합니다!") + private String finalTitle; + + @Schema(description = "최종 콘텐츠 내용") + private String finalContent; + + @Schema(description = "발행 상태", example = "PUBLISHED") + private String status; + + // CreationConditions에 필요한 필드들 + @Schema(description = "콘텐츠 카테고리", example = "메뉴소개") + private String category; + + @Schema(description = "구체적인 요구사항", example = "새로 출시된 시그니처 버거를 홍보하고 싶어요") + private String requirement; + + @Schema(description = "톤앤매너", example = "친근함") + private String toneAndManner; + + @Schema(description = "감정 강도", example = "보통") + private String emotionIntensity; + + @Schema(description = "이벤트명", example = "신메뉴 출시 이벤트") + private String eventName; + + @Schema(description = "이벤트 시작일", example = "2024-01-15") + private LocalDate startDate; + + @Schema(description = "이벤트 종료일", example = "2024-01-31") + private LocalDate endDate; +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/resources/application.yml b/smarketing-java/marketing-content/src/main/resources/application.yml new file mode 100644 index 0000000..10dc73d --- /dev/null +++ b/smarketing-java/marketing-content/src/main/resources/application.yml @@ -0,0 +1,33 @@ +server: + port: ${SERVER_PORT:8083} + +spring: + application: + name: marketing-content-service + datasource: + url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:MarketingContentDB} + username: ${POSTGRES_USER:postgres} + password: ${POSTGRES_PASSWORD:postgres} + driver-class-name: org.postgresql.Driver + jpa: + hibernate: + ddl-auto: ${DDL_AUTO:update} + show-sql: ${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:} + +jwt: + secret: ${JWT_SECRET:mySecretKeyForJWTTokenGenerationAndValidation123456789} + access-token-validity: ${JWT_ACCESS_VALIDITY:3600000} + refresh-token-validity: ${JWT_REFRESH_VALIDITY:604800000} + +logging: + level: + com.won.smarketing: ${LOG_LEVEL:DEBUG} diff --git a/smarketing-java/member/Jenkinsfile b/smarketing-java/member/Jenkinsfile new file mode 100644 index 0000000..3267d52 --- /dev/null +++ b/smarketing-java/member/Jenkinsfile @@ -0,0 +1,81 @@ +pipeline { + agent any + + environment { + ACR_LOGIN_SERVER = 'acrsmarketing17567.azurecr.io' + IMAGE_NAME = 'member' + MANIFEST_REPO = 'https://github.com/won-ktds/smarketing-manifest.git' + MANIFEST_PATH = 'member/deployment.yaml' + } + + stages { + stage('Checkout') { + steps { + checkout scm + } + } + + stage('Build') { + steps { + dir('member') { + sh './gradlew clean build -x test' + } + } + } + + stage('Test') { + steps { + dir('member') { + sh './gradlew test' + } + } + } + + stage('Build Docker Image') { + steps { + script { + def imageTag = "${BUILD_NUMBER}-${env.GIT_COMMIT.substring(0,8)}" + def fullImageName = "${ACR_LOGIN_SERVER}/${IMAGE_NAME}:${imageTag}" + + dir('member') { + sh "docker build -t ${fullImageName} ." + } + + withCredentials([usernamePassword(credentialsId: 'acr-credentials', usernameVariable: 'ACR_USERNAME', passwordVariable: 'ACR_PASSWORD')]) { + sh "docker login ${ACR_LOGIN_SERVER} -u ${ACR_USERNAME} -p ${ACR_PASSWORD}" + sh "docker push ${fullImageName}" + } + + env.IMAGE_TAG = imageTag + env.FULL_IMAGE_NAME = fullImageName + } + } + } + + stage('Update Manifest') { + steps { + withCredentials([usernamePassword(credentialsId: 'github-credentials', usernameVariable: 'GIT_USERNAME', passwordVariable: 'GIT_TOKEN')]) { + sh ''' + git clone https://${GIT_TOKEN}@github.com/won-ktds/smarketing-manifest.git manifest-repo + cd manifest-repo + + # Update image tag in deployment.yaml + sed -i "s|image: .*|image: ${FULL_IMAGE_NAME}|g" ${MANIFEST_PATH} + + git config user.email "jenkins@smarketing.com" + git config user.name "Jenkins" + git add . + git commit -m "Update ${IMAGE_NAME} image to ${IMAGE_TAG}" + git push origin main + ''' + } + } + } + } + + post { + always { + cleanWs() + } + } +} diff --git a/smarketing-java/member/build.gradle b/smarketing-java/member/build.gradle new file mode 100644 index 0000000..c75e760 --- /dev/null +++ b/smarketing-java/member/build.gradle @@ -0,0 +1,5 @@ +dependencies { + implementation project(':common') + // 데이터베이스 의존성 + runtimeOnly 'org.postgresql:postgresql' +} \ No newline at end of file diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/MemberServiceApplication.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/MemberServiceApplication.java new file mode 100644 index 0000000..d8f2305 --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/MemberServiceApplication.java @@ -0,0 +1,20 @@ +package com.won.smarketing.member; + +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; + +/** + * 회원 서비스 메인 애플리케이션 클래스 + * Spring Boot 애플리케이션의 진입점 + */ +@SpringBootApplication(scanBasePackages = {"com.won.smarketing.member", "com.won.smarketing.common"}) +@EntityScan(basePackages = {"com.won.smarketing.member.entity"}) +@EnableJpaRepositories(basePackages = {"com.won.smarketing.member.repository"}) +public class MemberServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(MemberServiceApplication.class, args); + } +} diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/config/JpaConfig.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/config/JpaConfig.java new file mode 100644 index 0000000..4d5037a --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/config/JpaConfig.java @@ -0,0 +1,13 @@ +package com.won.smarketing.member.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +/** + * JPA 설정 클래스 + * JPA Auditing 기능 활성화 + */ +@Configuration +@EnableJpaAuditing +public class JpaConfig { +} diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/controller/AuthController.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/controller/AuthController.java new file mode 100644 index 0000000..d3b1155 --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/controller/AuthController.java @@ -0,0 +1,64 @@ +package com.won.smarketing.member.controller; + +import com.won.smarketing.common.dto.ApiResponse; +import com.won.smarketing.member.dto.*; +import com.won.smarketing.member.service.AuthService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; + +/** + * 인증을 위한 REST API 컨트롤러 + * 로그인, 로그아웃, 토큰 갱신 기능 제공 + */ +@Tag(name = "인증 관리", description = "로그인, 로그아웃, 토큰 관리 API") +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + + /** + * 로그인 + * + * @param request 로그인 요청 정보 + * @return 로그인 성공 응답 (토큰 포함) + */ + @Operation(summary = "로그인", description = "사용자 ID와 패스워드로 로그인합니다.") + @PostMapping("/login") + public ResponseEntity> login(@Valid @RequestBody LoginRequest request) { + LoginResponse response = authService.login(request); + return ResponseEntity.ok(ApiResponse.success(response, "로그인이 완료되었습니다.")); + } + + /** + * 로그아웃 + * + * @param request 로그아웃 요청 정보 + * @return 로그아웃 성공 응답 + */ + @Operation(summary = "로그아웃", description = "리프레시 토큰을 무효화하여 로그아웃합니다.") + @PostMapping("/logout") + public ResponseEntity> logout(@Valid @RequestBody LogoutRequest request) { + authService.logout(request.getRefreshToken()); + return ResponseEntity.ok(ApiResponse.success(null, "로그아웃이 완료되었습니다.")); + } + + /** + * 토큰 갱신 + * + * @param request 토큰 갱신 요청 정보 + * @return 새로운 토큰 정보 + */ + @Operation(summary = "토큰 갱신", description = "리프레시 토큰을 사용하여 새로운 액세스 토큰을 발급받습니다.") + @PostMapping("/refresh") + public ResponseEntity> refresh(@Valid @RequestBody TokenRefreshRequest request) { + TokenResponse response = authService.refresh(request.getRefreshToken()); + return ResponseEntity.ok(ApiResponse.success(response, "토큰이 갱신되었습니다.")); + } +} diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/controller/MemberController.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/controller/MemberController.java new file mode 100644 index 0000000..e73728d --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/controller/MemberController.java @@ -0,0 +1,120 @@ +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 lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; + +/** + * 회원 관리를 위한 REST API 컨트롤러 + * 회원가입, 중복 확인, 패스워드 검증 기능 제공 + */ +@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> 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/user-id") + public ResponseEntity> checkUserIdDuplicate( + @Parameter(description = "확인할 사용자 ID", required = true) + @RequestParam String userId) { + + boolean isDuplicate = memberService.checkDuplicate(userId); + DuplicateCheckResponse response = isDuplicate + ? DuplicateCheckResponse.duplicate("이미 사용 중인 사용자 ID입니다.") + : DuplicateCheckResponse.available("사용 가능한 사용자 ID입니다."); + + return ResponseEntity.ok(ApiResponse.success(response)); + } + + /** + * 이메일 중복 확인 + * + * @param email 확인할 이메일 + * @return 중복 확인 결과 + */ + @Operation(summary = "이메일 중복 확인", description = "이메일의 중복 여부를 확인합니다.") + @GetMapping("/check-duplicate/email") + public ResponseEntity> checkEmailDuplicate( + @Parameter(description = "확인할 이메일", required = true) + @RequestParam String email) { + + boolean isDuplicate = memberService.checkEmailDuplicate(email); + DuplicateCheckResponse response = isDuplicate + ? DuplicateCheckResponse.duplicate("이미 사용 중인 이메일입니다.") + : DuplicateCheckResponse.available("사용 가능한 이메일입니다."); + + return ResponseEntity.ok(ApiResponse.success(response)); + } + + /** + * 사업자번호 중복 확인 + * + * @param businessNumber 확인할 사업자번호 + * @return 중복 확인 결과 + */ + @Operation(summary = "사업자번호 중복 확인", description = "사업자번호의 중복 여부를 확인합니다.") + @GetMapping("/check-duplicate/business-number") + public ResponseEntity> checkBusinessNumberDuplicate( + @Parameter(description = "확인할 사업자번호", required = true) + @RequestParam String businessNumber) { + + boolean isDuplicate = memberService.checkBusinessNumberDuplicate(businessNumber); + DuplicateCheckResponse response = isDuplicate + ? DuplicateCheckResponse.duplicate("이미 등록된 사업자번호입니다.") + : DuplicateCheckResponse.available("사용 가능한 사업자번호입니다."); + + return ResponseEntity.ok(ApiResponse.success(response)); + } + + /** + * 패스워드 유효성 검증 + * + * @param request 패스워드 검증 요청 + * @return 패스워드 검증 결과 + */ + @Operation(summary = "패스워드 검증", description = "패스워드가 규칙을 만족하는지 확인합니다.") + @PostMapping("/validate-password") + public ResponseEntity> validatePassword( + @Valid @RequestBody PasswordValidationRequest request) { + + ValidationResponse response = memberService.validatePassword(request.getPassword()); + return ResponseEntity.ok(ApiResponse.success(response)); + } +} + + + diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/DuplicateCheckResponse.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/DuplicateCheckResponse.java new file mode 100644 index 0000000..cf9e56b --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/DuplicateCheckResponse.java @@ -0,0 +1,54 @@ +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 + * 사용자 ID, 이메일 등의 중복 확인 결과를 전달합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "중복 확인 응답") +public class DuplicateCheckResponse { + + @Schema(description = "중복 여부", example = "false") + private boolean isDuplicate; + + @Schema(description = "메시지", example = "사용 가능한 ID입니다.") + private String message; + + /** + * 중복된 경우의 응답 생성 + * + * @param message 메시지 + * @return 중복 응답 + */ + public static DuplicateCheckResponse duplicate(String message) { + return DuplicateCheckResponse.builder() + .isDuplicate(true) + .message(message) + .build(); + } + + /** + * 사용 가능한 경우의 응답 생성 + * + * @param message 메시지 + * @return 사용 가능 응답 + */ + public static DuplicateCheckResponse available(String message) { + return DuplicateCheckResponse.builder() + .isDuplicate(false) + .message(message) + .build(); + } +} + + + diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/LoginRequest.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/LoginRequest.java new file mode 100644 index 0000000..d55ee0a --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/LoginRequest.java @@ -0,0 +1,26 @@ +package com.won.smarketing.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 로그인 요청 DTO + * 로그인 시 필요한 정보를 전달합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "로그인 요청") +public class LoginRequest { + + @Schema(description = "사용자 ID", example = "user123", required = true) + @NotBlank(message = "사용자 ID는 필수입니다") + private String userId; + + @Schema(description = "패스워드", example = "password123!", required = true) + @NotBlank(message = "패스워드는 필수입니다") + private String password; +} diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/LoginResponse.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/LoginResponse.java new file mode 100644 index 0000000..3c71e94 --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/LoginResponse.java @@ -0,0 +1,47 @@ +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 + * 로그인 성공 시 토큰 정보를 전달합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "로그인 응답") +public class LoginResponse { + + @Schema(description = "액세스 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + private String accessToken; + + @Schema(description = "리프레시 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + private String refreshToken; + + @Schema(description = "토큰 만료 시간 (초)", example = "3600") + private long expiresIn; + + @Schema(description = "사용자 정보") + private UserInfo userInfo; + + @Data + @NoArgsConstructor + @AllArgsConstructor + @Builder + @Schema(description = "사용자 정보") + public static class UserInfo { + @Schema(description = "사용자 ID", example = "user123") + private String userId; + + @Schema(description = "이름", example = "홍길동") + private String name; + + @Schema(description = "이메일", example = "user@example.com") + private String email; + } +} diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/LogoutRequest.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/LogoutRequest.java new file mode 100644 index 0000000..99008bf --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/LogoutRequest.java @@ -0,0 +1,25 @@ +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 jakarta.validation.constraints.NotBlank; + +/** + * 로그아웃 요청 DTO + * 로그아웃 시 무효화할 Refresh Token 정보 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "로그아웃 요청") +public class LogoutRequest { + + @Schema(description = "무효화할 Refresh Token", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", required = true) + @NotBlank(message = "Refresh Token은 필수입니다.") + private String refreshToken; +} diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/PasswordValidationRequest.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/PasswordValidationRequest.java new file mode 100644 index 0000000..b2d96aa --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/PasswordValidationRequest.java @@ -0,0 +1,22 @@ +package com.won.smarketing.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 패스워드 검증 요청 DTO + * 패스워드 규칙 검증을 위한 요청 정보를 전달합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "패스워드 검증 요청") +public class PasswordValidationRequest { + + @Schema(description = "검증할 패스워드", example = "password123!", required = true) + @NotBlank(message = "패스워드는 필수입니다") + private String password; +} diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/RegisterRequest.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/RegisterRequest.java new file mode 100644 index 0000000..b0cad33 --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/RegisterRequest.java @@ -0,0 +1,49 @@ +package com.won.smarketing.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 회원가입 요청 DTO + * 회원가입 시 필요한 정보를 전달합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "회원가입 요청") +public class RegisterRequest { + + @Schema(description = "사용자 ID", example = "user123", 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 = "패스워드는 필수입니다") + @Size(min = 8, max = 20, message = "패스워드는 8-20자 사이여야 합니다") + @Pattern(regexp = "^(?=.*[a-zA-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]+$", + message = "패스워드는 영문, 숫자, 특수문자를 포함해야 합니다") + private String password; + + @Schema(description = "이름", example = "홍길동", required = true) + @NotBlank(message = "이름은 필수입니다") + @Size(max = 50, message = "이름은 50자 이하여야 합니다") + private String name; + + @Schema(description = "사업자등록번호", example = "1234567890") + @Pattern(regexp = "^\\d{10}$", message = "사업자등록번호는 10자리 숫자여야 합니다") + private String businessNumber; + + @Schema(description = "이메일", example = "user@example.com", required = true) + @NotBlank(message = "이메일은 필수입니다") + @Email(message = "이메일 형식이 올바르지 않습니다") + @Size(max = 100, message = "이메일은 100자 이하여야 합니다") + private String email; +} diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/TokenRefreshRequest.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/TokenRefreshRequest.java new file mode 100644 index 0000000..7278ab5 --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/TokenRefreshRequest.java @@ -0,0 +1,22 @@ +package com.won.smarketing.member.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 토큰 갱신 요청 DTO + * 리프레시 토큰을 사용한 액세스 토큰 갱신 요청 정보를 전달합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "토큰 갱신 요청") +public class TokenRefreshRequest { + + @Schema(description = "리프레시 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", required = true) + @NotBlank(message = "리프레시 토큰은 필수입니다") + private String refreshToken; +} diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/TokenResponse.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/TokenResponse.java new file mode 100644 index 0000000..a750def --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/TokenResponse.java @@ -0,0 +1,28 @@ +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 + * 토큰 갱신 시 새로운 토큰 정보를 전달합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "토큰 응답") +public class TokenResponse { + + @Schema(description = "새로운 액세스 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + private String accessToken; + + @Schema(description = "새로운 리프레시 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...") + private String refreshToken; + + @Schema(description = "토큰 만료 시간 (초)", example = "3600") + private long expiresIn; +} diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/ValidationResponse.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/ValidationResponse.java new file mode 100644 index 0000000..4808fec --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/dto/ValidationResponse.java @@ -0,0 +1,58 @@ +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 = "오류 목록", example = "[\"영문이 포함되어야 합니다\", \"숫자가 포함되어야 합니다\"]") + private List errors; + + /** + * 유효한 경우의 응답 생성 + * + * @param message 메시지 + * @return 유효 응답 + */ + public static ValidationResponse valid(String message) { + return ValidationResponse.builder() + .isValid(true) + .message(message) + .build(); + } + + /** + * 유효하지 않은 경우의 응답 생성 + * + * @param message 메시지 + * @param errors 오류 목록 + * @return 무효 응답 + */ + public static ValidationResponse invalid(String message, List errors) { + return ValidationResponse.builder() + .isValid(false) + .message(message) + .errors(errors) + .build(); + } +} diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/entity/Member.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/entity/Member.java new file mode 100644 index 0000000..89bb592 --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/entity/Member.java @@ -0,0 +1,82 @@ +package com.won.smarketing.member.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +/** + * 회원 엔티티 + * 회원의 기본 정보와 사업자 정보를 관리 + */ +@Entity +@Table(name = "members") +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@EntityListeners(AuditingEntityListener.class) +public class Member { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "member_id") + private Long id; + + @Column(name = "user_id", nullable = false, unique = true, length = 50) + private String userId; + + @Column(name = "password", nullable = false, length = 100) + private String password; + + @Column(name = "name", nullable = false, length = 50) + private String name; + + @Column(name = "business_number", length = 15, unique = true) + private String businessNumber; + + @Column(name = "email", nullable = false, unique = true, length = 100) + private String email; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + /** + * 회원 정보 업데이트 + * + * @param name 이름 + * @param email 이메일 + * @param businessNumber 사업자번호 + */ + public void updateProfile(String name, String email, String businessNumber) { + if (name != null && !name.trim().isEmpty()) { + this.name = name; + } + if (email != null && !email.trim().isEmpty()) { + this.email = email; + } + if (businessNumber != null && !businessNumber.trim().isEmpty()) { + this.businessNumber = businessNumber; + } + } + + /** + * 패스워드 변경 + * + * @param encodedPassword 암호화된 패스워드 + */ + public void changePassword(String encodedPassword) { + this.password = encodedPassword; + } +} diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/repository/MemberRepository.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/repository/MemberRepository.java new file mode 100644 index 0000000..eec42ea --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/repository/MemberRepository.java @@ -0,0 +1,47 @@ +package com.won.smarketing.member.repository; + +import com.won.smarketing.member.entity.Member; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * 회원 정보 데이터 접근을 위한 Repository + * JPA를 사용한 회원 CRUD 작업 처리 + */ +@Repository +public interface MemberRepository extends JpaRepository { + + /** + * 사용자 ID로 회원 조회 + * + * @param userId 사용자 ID + * @return 회원 정보 (Optional) + */ + Optional findByUserId(String userId); + + /** + * 사용자 ID 존재 여부 확인 + * + * @param userId 사용자 ID + * @return 존재 여부 + */ + boolean existsByUserId(String userId); + + /** + * 이메일 존재 여부 확인 + * + * @param email 이메일 + * @return 존재 여부 + */ + boolean existsByEmail(String email); + + /** + * 사업자번호 존재 여부 확인 + * + * @param businessNumber 사업자번호 + * @return 존재 여부 + */ + boolean existsByBusinessNumber(String businessNumber); +} diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/service/AuthService.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/service/AuthService.java new file mode 100644 index 0000000..c73bc1f --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/service/AuthService.java @@ -0,0 +1,35 @@ +package com.won.smarketing.member.service; + +import com.won.smarketing.member.dto.LoginRequest; +import com.won.smarketing.member.dto.LoginResponse; +import com.won.smarketing.member.dto.TokenResponse; + +/** + * 인증 서비스 인터페이스 + * 로그인, 로그아웃, 토큰 갱신 관련 비즈니스 로직 정의 + */ +public interface AuthService { + + /** + * 로그인 + * + * @param request 로그인 요청 정보 + * @return 로그인 응답 정보 (토큰 포함) + */ + LoginResponse login(LoginRequest request); + + /** + * 로그아웃 + * + * @param refreshToken 리프레시 토큰 + */ + void logout(String refreshToken); + + /** + * 토큰 갱신 + * + * @param refreshToken 리프레시 토큰 + * @return 새로운 토큰 정보 + */ + TokenResponse refresh(String refreshToken); +} diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/service/AuthServiceImpl.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/service/AuthServiceImpl.java new file mode 100644 index 0000000..694e93a --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/service/AuthServiceImpl.java @@ -0,0 +1,166 @@ +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 lombok.extern.slf4j.Slf4j; +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; + +/** + * 인증 서비스 구현체 + * 로그인, 로그아웃, 토큰 갱신 기능 구현 + */ +@Slf4j +@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 redisTemplate; + + private static final String REFRESH_TOKEN_PREFIX = "refresh_token:"; + private static final String BLACKLIST_PREFIX = "blacklist:"; + + /** + * 로그인 + * + * @param request 로그인 요청 정보 + * @return 로그인 응답 정보 (토큰 포함) + */ + @Override + @Transactional + public LoginResponse login(LoginRequest request) { + log.info("로그인 시도: {}", request.getUserId()); + + // 회원 조회 + Member member = memberRepository.findByUserId(request.getUserId()) + .orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND)); + + // 패스워드 검증 + if (!passwordEncoder.matches(request.getPassword(), member.getPassword())) { + System.out.println(passwordEncoder.encode(request.getPassword())); + System.out.println(passwordEncoder.encode(member.getPassword())); + + throw new BusinessException(ErrorCode.INVALID_PASSWORD); + } + + // 토큰 생성 + String accessToken = jwtTokenProvider.generateAccessToken(member.getUserId()); + String refreshToken = jwtTokenProvider.generateRefreshToken(member.getUserId()); + + log.info("{} access token 발급: {}", request.getUserId(), accessToken); + + // 리프레시 토큰을 Redis에 저장 (7일) + redisTemplate.opsForValue().set( + REFRESH_TOKEN_PREFIX + member.getUserId(), + refreshToken, + 7, + TimeUnit.DAYS + ); + + log.info("로그인 성공: {}", request.getUserId()); + + return LoginResponse.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .expiresIn(jwtTokenProvider.getAccessTokenValidityTime() / 1000) + .userInfo(LoginResponse.UserInfo.builder() + .userId(member.getUserId()) + .name(member.getName()) + .email(member.getEmail()) + .build()) + .build(); + } + + /** + * 로그아웃 + * + * @param refreshToken 리프레시 토큰 + */ + @Override + @Transactional + public void logout(String refreshToken) { + try { + if (jwtTokenProvider.validateToken(refreshToken)) { + String userId = jwtTokenProvider.getUserIdFromToken(refreshToken); + + redisTemplate.delete(REFRESH_TOKEN_PREFIX + userId); + + log.info("로그아웃 완료: {}", userId); + } + } catch (Exception ex) { + log.warn("로그아웃 처리 중 오류 발생: {}", ex.getMessage()); + // 로그아웃은 실패해도 클라이언트에게는 성공으로 응답 + } + } + + /** + * 토큰 갱신 + * + * @param refreshToken 리프레시 토큰 + * @return 새로운 토큰 정보 + */ + @Override + @Transactional + public TokenResponse refresh(String refreshToken) { + // 토큰 유효성 검증 + if (!jwtTokenProvider.validateToken(refreshToken)) { + throw new BusinessException(ErrorCode.INVALID_TOKEN); + } + + // 블랙리스트 확인 + if (redisTemplate.hasKey(BLACKLIST_PREFIX + refreshToken)) { + throw new BusinessException(ErrorCode.INVALID_TOKEN); + } + + String userId = jwtTokenProvider.getUserIdFromToken(refreshToken); + + // Redis에 저장된 리프레시 토큰과 비교 + String storedRefreshToken = redisTemplate.opsForValue().get(REFRESH_TOKEN_PREFIX + userId); + if (!refreshToken.equals(storedRefreshToken)) { + throw new BusinessException(ErrorCode.INVALID_TOKEN); + } + + // 회원 존재 확인 + if (!memberRepository.existsByUserId(userId)) { + throw new BusinessException(ErrorCode.MEMBER_NOT_FOUND); + } + + // 새로운 토큰 생성 + String newAccessToken = jwtTokenProvider.generateAccessToken(userId); + String newRefreshToken = jwtTokenProvider.generateRefreshToken(userId); + + // 새로운 리프레시 토큰을 Redis에 저장 + redisTemplate.opsForValue().set( + REFRESH_TOKEN_PREFIX + userId, + newRefreshToken, + 7, + TimeUnit.DAYS + ); + + // 기존 리프레시 토큰 삭제 + redisTemplate.delete(REFRESH_TOKEN_PREFIX + userId); + + log.info("토큰 갱신 완료: {}", userId); + + return TokenResponse.builder() + .accessToken(newAccessToken) + .refreshToken(newRefreshToken) + .expiresIn(jwtTokenProvider.getAccessTokenValidityTime() / 1000) + .build(); + } +} \ No newline at end of file diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/service/MemberService.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/service/MemberService.java new file mode 100644 index 0000000..c1e456f --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/service/MemberService.java @@ -0,0 +1,50 @@ +package com.won.smarketing.member.service; + +import com.won.smarketing.member.dto.RegisterRequest; +import com.won.smarketing.member.dto.ValidationResponse; + +/** + * 회원 서비스 인터페이스 + * 회원 관리 관련 비즈니스 로직 정의 + */ +public interface MemberService { + + /** + * 회원 등록 + * + * @param request 회원가입 요청 정보 + */ + void register(RegisterRequest request); + + /** + * 사용자 ID 중복 확인 + * + * @param userId 확인할 사용자 ID + * @return 중복 여부 + */ + boolean checkDuplicate(String userId); + + /** + * 이메일 중복 확인 + * + * @param email 확인할 이메일 + * @return 중복 여부 + */ + boolean checkEmailDuplicate(String email); + + /** + * 사업자번호 중복 확인 + * + * @param businessNumber 확인할 사업자번호 + * @return 중복 여부 + */ + boolean checkBusinessNumberDuplicate(String businessNumber); + + /** + * 패스워드 유효성 검증 + * + * @param password 검증할 패스워드 + * @return 검증 결과 + */ + ValidationResponse validatePassword(String password); +} diff --git a/smarketing-java/member/src/main/java/com/won/smarketing/member/service/MemberServiceImpl.java b/smarketing-java/member/src/main/java/com/won/smarketing/member/service/MemberServiceImpl.java new file mode 100644 index 0000000..8c730d2 --- /dev/null +++ b/smarketing-java/member/src/main/java/com/won/smarketing/member/service/MemberServiceImpl.java @@ -0,0 +1,146 @@ +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 lombok.extern.slf4j.Slf4j; +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; + +/** + * 회원 서비스 구현체 + * 회원 등록, 중복 확인, 패스워드 검증 기능 구현 + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MemberServiceImpl implements MemberService { + + private final MemberRepository memberRepository; + private final PasswordEncoder passwordEncoder; + + // 패스워드 검증 패턴 + private static final Pattern LETTER_PATTERN = Pattern.compile(".*[a-zA-Z].*"); + private static final Pattern DIGIT_PATTERN = Pattern.compile(".*\\d.*"); + private static final Pattern SPECIAL_CHAR_PATTERN = Pattern.compile(".*[@$!%*?&].*"); + + /** + * 회원 등록 + * + * @param request 회원가입 요청 정보 + */ + @Override + @Transactional + public void register(RegisterRequest request) { + log.info("회원 등록 시작: {}", request.getUserId()); + + // 중복 확인 + if (memberRepository.existsByUserId(request.getUserId())) { + throw new BusinessException(ErrorCode.DUPLICATE_MEMBER_ID); + } + + if (memberRepository.existsByEmail(request.getEmail())) { + throw new BusinessException(ErrorCode.DUPLICATE_EMAIL); + } + + if (request.getBusinessNumber() != null && + memberRepository.existsByBusinessNumber(request.getBusinessNumber())) { + throw new BusinessException(ErrorCode.DUPLICATE_BUSINESS_NUMBER); + } + + // 회원 엔티티 생성 및 저장 + Member member = Member.builder() + .userId(request.getUserId()) + .password(passwordEncoder.encode(request.getPassword())) + .name(request.getName()) + .businessNumber(request.getBusinessNumber()) + .email(request.getEmail()) + .build(); + + memberRepository.save(member); + log.info("회원 등록 완료: {}", request.getUserId()); + } + + /** + * 사용자 ID 중복 확인 + * + * @param userId 확인할 사용자 ID + * @return 중복 여부 + */ + @Override + public boolean checkDuplicate(String userId) { + return memberRepository.existsByUserId(userId); + } + + /** + * 이메일 중복 확인 + * + * @param email 확인할 이메일 + * @return 중복 여부 + */ + @Override + public boolean checkEmailDuplicate(String email) { + return memberRepository.existsByEmail(email); + } + + /** + * 사업자번호 중복 확인 + * + * @param businessNumber 확인할 사업자번호 + * @return 중복 여부 + */ + @Override + public boolean checkBusinessNumberDuplicate(String businessNumber) { + if (businessNumber == null || businessNumber.trim().isEmpty()) { + return false; + } + return memberRepository.existsByBusinessNumber(businessNumber); + } + + /** + * 패스워드 유효성 검증 + * + * @param password 검증할 패스워드 + * @return 검증 결과 + */ + @Override + public ValidationResponse validatePassword(String password) { + List errors = new ArrayList<>(); + + // 길이 검증 + if (password.length() < 8 || password.length() > 20) { + errors.add("패스워드는 8-20자 사이여야 합니다"); + } + + // 영문 포함 여부 + if (!LETTER_PATTERN.matcher(password).matches()) { + errors.add("영문이 포함되어야 합니다"); + } + + // 숫자 포함 여부 + if (!DIGIT_PATTERN.matcher(password).matches()) { + errors.add("숫자가 포함되어야 합니다"); + } + + // 특수문자 포함 여부 + if (!SPECIAL_CHAR_PATTERN.matcher(password).matches()) { + errors.add("특수문자(@$!%*?&)가 포함되어야 합니다"); + } + + if (errors.isEmpty()) { + return ValidationResponse.valid("사용 가능한 패스워드입니다."); + } else { + return ValidationResponse.invalid("패스워드 규칙을 확인해 주세요.", errors); + } + } +} diff --git a/smarketing-java/member/src/main/resources/application.yml b/smarketing-java/member/src/main/resources/application.yml new file mode 100644 index 0000000..511b56f --- /dev/null +++ b/smarketing-java/member/src/main/resources/application.yml @@ -0,0 +1,33 @@ +server: + port: ${MEMBER_PORT:8081} + +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} + driver-class-name: org.postgresql.Driver + jpa: + hibernate: + ddl-auto: ${DDL_AUTO:update} + show-sql: ${SHOW_SQL:true} + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + format_sql: true + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6380} + password: ${REDIS_PASSWORD:} + +jwt: + secret: ${JWT_SECRET:mySecretKeyForJWTTokenGenerationAndValidation123456789} + access-token-validity: ${JWT_ACCESS_VALIDITY:3600000} + refresh-token-validity: ${JWT_REFRESH_VALIDITY:604800000} + +logging: + level: + com.won.smarketing: ${LOG_LEVEL:DEBUG} diff --git a/smarketing-java/member/src/main/resources/data.sql b/smarketing-java/member/src/main/resources/data.sql new file mode 100644 index 0000000..8404deb --- /dev/null +++ b/smarketing-java/member/src/main/resources/data.sql @@ -0,0 +1,18 @@ +INSERT INTO members (member_id, user_id, password, name, business_number, email, created_at, updated_at) +VALUES + (DEFAULT, 'testuser1', '$2a$10$27tA6hwHt4N94WzZm/xqv.smgDi3c6cVp.Pu8gVyfqlEdwTPI8r7y', '김소상', '123-45-67890', 'test1@smarketing.com', NOW(), NOW()), + (DEFAULT, 'testuser2', '$2a$10$27tA6hwHt4N94WzZm/xqv.smgDi3c6cVp.Pu8gVyfqlEdwTPI8r7y', '이점주', '234-56-78901', 'test2@smarketing.com', NOW(), NOW()), + (DEFAULT, 'testuser3', '$2a$10$27tA6hwHt4N94WzZm/xqv.smgDi3c6cVp.Pu8gVyfqlEdwTPI8r7y', '박카페', '345-67-89012', 'test3@smarketing.com', NOW(), NOW()), + (DEFAULT, 'cafeowner1', '$2a$10$27tA6hwHt4N94WzZm/xqv.smgDi3c6cVp.Pu8gVyfqlEdwTPI8r7y', '최카페', '456-78-90123', 'cafe@smarketing.com', NOW(), NOW()), + (DEFAULT, 'restaurant1', '$2a$10$27tA6hwHt4N94WzZm/xqv.smgDi3c6cVp.Pu8gVyfqlEdwTPI8r7y', '정식당', '567-89-01234', 'restaurant@smarketing.com', NOW(), NOW()) +ON CONFLICT (user_id) DO NOTHING; + +-- 이메일 중복 방지를 위한 추가 체크 +INSERT INTO members (member_id, user_id, password, name, business_number, email, created_at, updated_at) +VALUES + (DEFAULT, 'bakery1', '$2a$10$27tA6hwHt4N94WzZm/xqv.smgDi3c6cVp.Pu8gVyfqlEdwTPI8r7y', '김베이커리', '678-90-12345', 'bakery@smarketing.com', NOW(), NOW()), + (DEFAULT, 'chicken1', '$2a$10$27tA6hwHt4N94WzZm/xqv.smgDi3c6cVp.Pu8gVyfqlEdwTPI8r7y', '한치킨', '789-01-23456', 'chicken@smarketing.com', NOW(), NOW()), + (DEFAULT, 'pizza1', '$2a$10$27tA6hwHt4N94WzZm/xqv.smgDi3c6cVp.Pu8gVyfqlEdwTPI8r7y', '이피자', '890-12-34567', 'pizza@smarketing.com', NOW(), NOW()), + (DEFAULT, 'dessert1', '$2a$10$27tA6hwHt4N94WzZm/xqv.smgDi3c6cVp.Pu8gVyfqlEdwTPI8r7y', '달디저트', '901-23-45678', 'dessert@smarketing.com', NOW(), NOW()), + (DEFAULT, 'beauty1', '$2a$10$27tA6hwHt4N94WzZm/xqv.smgDi3c6cVp.Pu8gVyfqlEdwTPI8r7y', '미뷰티샵', '012-34-56789', 'beauty@smarketing.com', NOW(), NOW()) +ON CONFLICT (user_id) DO NOTHING; diff --git a/smarketing-java/settings.gradle b/smarketing-java/settings.gradle new file mode 100644 index 0000000..54fbe0d --- /dev/null +++ b/smarketing-java/settings.gradle @@ -0,0 +1,6 @@ +rootProject.name = 'smarketing' +include 'common' +include 'member' +include 'store' +include 'marketing-content' +include 'ai-recommend' \ No newline at end of file diff --git a/smarketing-java/store/build.gradle b/smarketing-java/store/build.gradle new file mode 100644 index 0000000..dd9e26d --- /dev/null +++ b/smarketing-java/store/build.gradle @@ -0,0 +1,8 @@ +dependencies { + implementation project(':common') + runtimeOnly 'com.mysql:mysql-connector-j' + + // Azure Blob Storage 의존성 추가 + implementation 'com.azure:azure-storage-blob:12.25.0' + implementation 'com.azure:azure-identity:1.11.1' +} \ No newline at end of file diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/StoreServiceApplication.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/StoreServiceApplication.java new file mode 100644 index 0000000..8d9a7a5 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/StoreServiceApplication.java @@ -0,0 +1,20 @@ +package com.won.smarketing.store; + +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; + +/** + * 매장 서비스 메인 애플리케이션 클래스 + * Spring Boot 애플리케이션의 진입점 + */ +@SpringBootApplication(scanBasePackages = {"com.won.smarketing.store", "com.won.smarketing.common"}) +@EntityScan(basePackages = {"com.won.smarketing.store.entity"}) +@EnableJpaRepositories(basePackages = {"com.won.smarketing.store.repository"}) +public class StoreServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(StoreServiceApplication.class, args); + } +} diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/config/AzureBlobStorageConfig.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/config/AzureBlobStorageConfig.java new file mode 100644 index 0000000..ffa6bef --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/config/AzureBlobStorageConfig.java @@ -0,0 +1,72 @@ +// store/src/main/java/com/won/smarketing/store/config/AzureBlobStorageConfig.java +package com.won.smarketing.store.config; + +import com.azure.identity.DefaultAzureCredentialBuilder; +import com.azure.storage.blob.BlobServiceClient; +import com.azure.storage.blob.BlobServiceClientBuilder; +import com.azure.storage.common.StorageSharedKeyCredential; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Azure Blob Storage 설정 클래스 + * Azure Blob Storage와의 연결을 위한 설정 + */ +@Configuration +@Slf4j +public class AzureBlobStorageConfig { + + @Value("${azure.storage.account-name}") + private String accountName; + + @Value("${azure.storage.account-key:}") + private String accountKey; + + @Value("${azure.storage.endpoint:}") + private String endpoint; + + /** + * Azure Blob Storage Service Client 생성 + * + * @return BlobServiceClient 인스턴스 + */ + @Bean + public BlobServiceClient blobServiceClient() { + try { + // Managed Identity 사용 시 (Azure 환경에서 권장) + if (accountKey == null || accountKey.isEmpty()) { + log.info("Azure Blob Storage 연결 - Managed Identity 사용"); + return new BlobServiceClientBuilder() + .endpoint(getEndpoint()) + .credential(new DefaultAzureCredentialBuilder().build()) + .buildClient(); + } + + // Account Key 사용 시 (개발 환경용) + log.info("Azure Blob Storage 연결 - Account Key 사용"); + StorageSharedKeyCredential credential = new StorageSharedKeyCredential(accountName, accountKey); + return new BlobServiceClientBuilder() + .endpoint(getEndpoint()) + .credential(credential) + .buildClient(); + + } catch (Exception e) { + log.error("Azure Blob Storage 클라이언트 생성 실패", e); + throw new RuntimeException("Azure Blob Storage 연결 실패", e); + } + } + + /** + * Storage Account 엔드포인트 URL 생성 + * + * @return 엔드포인트 URL + */ + private String getEndpoint() { + if (endpoint != null && !endpoint.isEmpty()) { + return endpoint; + } + return String.format("https://%s.blob.core.windows.net", accountName); + } +} \ No newline at end of file diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/config/JpaConfig.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/config/JpaConfig.java new file mode 100644 index 0000000..3c7e2f9 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/config/JpaConfig.java @@ -0,0 +1,31 @@ +package com.won.smarketing.store.config; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +/** + * JPA 설정 클래스 + * JPA Auditing 기능 활성화 + */ +@Configuration +@EnableJpaAuditing +public class JpaConfig { + private String category; + + @Schema(description = "가격", example = "4500", required = true) + @NotNull(message = "가격은 필수입니다") + @Min(value = 0, message = "가격은 0원 이상이어야 합니다") + private Integer price; + + @Schema(description = "메뉴 설명", example = "진한 맛의 아메리카노") + @Size(max = 500, message = "메뉴 설명은 500자 이하여야 합니다") + private String description; + + @Schema(description = "이미지 URL", example = "https://example.com/americano.jpg") + @Size(max = 500, message = "이미지 URL은 500자 이하여야 합니다") + private String image; +} diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/controller/ImageController.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/controller/ImageController.java new file mode 100644 index 0000000..adadce8 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/controller/ImageController.java @@ -0,0 +1,155 @@ +// store/src/main/java/com/won/smarketing/store/controller/ImageController.java +package com.won.smarketing.store.controller; + +import com.won.smarketing.store.dto.ImageUploadResponse; +import com.won.smarketing.store.dto.MenuResponse; +import com.won.smarketing.store.dto.StoreResponse; +import com.won.smarketing.store.service.BlobStorageService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +/** + * 이미지 업로드 API 컨트롤러 + * 메뉴 이미지, 매장 이미지 업로드 기능 제공 + */ +@RestController +@RequestMapping("/api/images") +@RequiredArgsConstructor +@Slf4j +@Tag(name = "이미지 업로드 API", description = "메뉴 및 매장 이미지 업로드 관리") +public class ImageController { + + private final BlobStorageService blobStorageService; + + /** + * 메뉴 이미지 업로드 + * + * @param menuId 메뉴 ID + * @param file 업로드할 이미지 파일 + * @return 업로드 결과 + */ + @PostMapping(value = "/menu/{menuId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "메뉴 이미지 업로드", description = "메뉴의 이미지를 Azure Blob Storage에 업로드합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "이미지 업로드 성공", + content = @Content(schema = @Schema(implementation = ImageUploadResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청 (파일 형식, 크기 등)"), + @ApiResponse(responseCode = "404", description = "메뉴를 찾을 수 없음"), + @ApiResponse(responseCode = "500", description = "서버 오류") + }) + public ResponseEntity uploadMenuImage( + @Parameter(description = "메뉴 ID", required = true) + @PathVariable Long menuId, + @Parameter(description = "업로드할 이미지 파일", required = true) + @RequestParam("file") MultipartFile file) { + + log.info("메뉴 이미지 업로드 요청 - 메뉴 ID: {}, 파일: {}", menuId, file.getOriginalFilename()); + + MenuResponse response = blobStorageService.uploadMenuImage(file, menuId); + + return ResponseEntity.ok(response); + } + + /** + * 매장 이미지 업로드 + * + * @param storeId 매장 ID + * @param file 업로드할 이미지 파일 + * @return 업로드 결과 + */ + @PostMapping(value = "/store/{storeId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation(summary = "매장 이미지 업로드", description = "매장의 이미지를 Azure Blob Storage에 업로드합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "이미지 업로드 성공", + content = @Content(schema = @Schema(implementation = ImageUploadResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청 (파일 형식, 크기 등)"), + @ApiResponse(responseCode = "404", description = "매장을 찾을 수 없음"), + @ApiResponse(responseCode = "500", description = "서버 오류") + }) + public ResponseEntity uploadStoreImage( + @Parameter(description = "매장 ID", required = true) + @PathVariable Long storeId, + @Parameter(description = "업로드할 이미지 파일", required = true) + @RequestParam("file") MultipartFile file) { + + log.info("매장 이미지 업로드 요청 - 매장 ID: {}, 파일: {}", storeId, file.getOriginalFilename()); + StoreResponse response = blobStorageService.uploadStoreImage(file, storeId); + + return ResponseEntity.ok(response); + } + + /** + * 이미지 삭제 + * + * @param imageUrl 삭제할 이미지 URL + * @return 삭제 결과 + */ + //@DeleteMapping + //@Operation(summary = "이미지 삭제", description = "Azure Blob Storage에서 이미지를 삭제합니다.") +// @ApiResponses(value = { +// @ApiResponse(responseCode = "200", description = "이미지 삭제 성공"), +// @ApiResponse(responseCode = "400", description = "잘못된 요청"), +// @ApiResponse(responseCode = "404", description = "이미지를 찾을 수 없음"), +// @ApiResponse(responseCode = "500", description = "서버 오류") +// }) +// public ResponseEntity deleteImage( +// @Parameter(description = "삭제할 이미지 URL", required = true) +// @RequestParam String imageUrl) { +// +// log.info("이미지 삭제 요청 - URL: {}", imageUrl); +// +// try { +// boolean deleted = blobStorageService.deleteFile(imageUrl); +// +// ImageUploadResponse response = ImageUploadResponse.builder() +// .imageUrl(imageUrl) +// .success(deleted) +// .message(deleted ? "이미지 삭제가 완료되었습니다." : "삭제할 이미지를 찾을 수 없습니다.") +// .build(); +// +// return ResponseEntity.ok(response); +// +// } catch (Exception e) { +// log.error("이미지 삭제 실패 - URL: {}", imageUrl, e); +// +// ImageUploadResponse response = ImageUploadResponse.builder() +// .imageUrl(imageUrl) +// .success(false) +// .message("이미지 삭제에 실패했습니다: " + e.getMessage()) +// .build(); +// +// return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); +// } +// } + + /** + * URL에서 파일명 추출 + * + * @param url 파일 URL + * @return 파일명 + */ + private String extractFileNameFromUrl(String url) { + if (url == null || url.isEmpty()) { + return null; + } + + try { + return url.substring(url.lastIndexOf('/') + 1); + } catch (Exception e) { + log.warn("URL에서 파일명 추출 실패: {}", url); + return null; + } + } +} \ No newline at end of file diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/controller/MenuController.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/controller/MenuController.java new file mode 100644 index 0000000..906a96f --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/controller/MenuController.java @@ -0,0 +1,98 @@ +package com.won.smarketing.store.controller; + +import com.won.smarketing.common.dto.ApiResponse; +import com.won.smarketing.store.dto.ImageUploadResponse; +import com.won.smarketing.store.dto.MenuCreateRequest; +import com.won.smarketing.store.dto.MenuResponse; +import com.won.smarketing.store.dto.MenuUpdateRequest; +import com.won.smarketing.store.service.BlobStorageService; +import com.won.smarketing.store.service.MenuService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +/** + * 메뉴 관리를 위한 REST API 컨트롤러 + * 메뉴 등록, 조회, 수정, 삭제 기능 제공 + */ +@Tag(name = "메뉴 관리", description = "메뉴 정보 관리 API") +@RestController +@RequestMapping("/api/menu") +@RequiredArgsConstructor +public class MenuController { + + private final MenuService menuService; + + /** + * 메뉴 정보 등록 + * + * @param request 메뉴 등록 요청 정보 + * @return 등록된 메뉴 정보 + */ + @Operation(summary = "메뉴 등록", description = "새로운 메뉴를 등록합니다.") + @PostMapping("/register") + public ResponseEntity> register(@Valid @RequestBody MenuCreateRequest request) { + MenuResponse response = menuService.register(request); + return ResponseEntity.ok(ApiResponse.success(response, "메뉴가 성공적으로 등록되었습니다.")); + } + + /** + * 메뉴 목록 조회 + * + * @param storeId 메뉴 카테고리 + * @return 메뉴 목록 + */ + @Operation(summary = "메뉴 목록 조회", description = "메뉴 목록을 조회합니다. 카테고리별 필터링 가능합니다.") + @GetMapping + public ResponseEntity>> getMenus( + @Parameter(description = "가게 ID") + @RequestParam(required = true) Long storeId) { + List response = menuService.getMenus(storeId); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + /** + * 메뉴 정보 수정 + * + * @param menuId 수정할 메뉴 ID + * @param request 메뉴 수정 요청 정보 + * @return 수정된 메뉴 정보 + */ + @Operation(summary = "메뉴 수정", description = "메뉴 정보를 수정합니다.") + @PutMapping("/{menuId}") + public ResponseEntity> updateMenu( + @Parameter(description = "메뉴 ID", required = true) + @PathVariable Long menuId, + @Valid @RequestBody MenuUpdateRequest request) { + MenuResponse response = menuService.updateMenu(menuId, request); + return ResponseEntity.ok(ApiResponse.success(response, "메뉴가 성공적으로 수정되었습니다.")); + } + + /** + * 메뉴 삭제 + * + * @param menuId 삭제할 메뉴 ID + * @return 삭제 성공 응답 + */ + @Operation(summary = "메뉴 삭제", description = "메뉴를 삭제합니다.") + @DeleteMapping("/{menuId}") + public ResponseEntity> deleteMenu( + @Parameter(description = "메뉴 ID", required = true) + @PathVariable Long menuId) { + menuService.deleteMenu(menuId); + return ResponseEntity.ok(ApiResponse.success(null, "메뉴가 성공적으로 삭제되었습니다.")); + } +} diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/controller/SalesController.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/controller/SalesController.java new file mode 100644 index 0000000..eab1e76 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/controller/SalesController.java @@ -0,0 +1,43 @@ +package com.won.smarketing.store.controller; + +import com.won.smarketing.common.dto.ApiResponse; +import com.won.smarketing.store.dto.SalesResponse; +import com.won.smarketing.store.service.SalesService; +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 org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 매출 정보를 위한 REST API 컨트롤러 + * 매출 조회 기능 제공 + */ +@Tag(name = "매출 관리", description = "매출 정보 조회 API") +@RestController +@RequestMapping("/api/sales") +@RequiredArgsConstructor +public class SalesController { + + private final SalesService salesService; + + /** + * 매출 정보 조회 + * + * @param storeId 가게 ID + * @return 매출 정보 (오늘, 월간, 전일 대비) + */ + @Operation(summary = "매출 조회", description = "오늘 매출, 월간 매출, 전일 대비 매출 정보를 조회합니다.") + @GetMapping("/{storeId}") + public ResponseEntity> getSales( + @Parameter(description = "가게 ID", required = true) + @PathVariable Long storeId + ) { + SalesResponse response = salesService.getSales(storeId); + return ResponseEntity.ok(ApiResponse.success(response)); + } +} diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/controller/StoreController.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/controller/StoreController.java new file mode 100644 index 0000000..3e12344 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/controller/StoreController.java @@ -0,0 +1,75 @@ +package com.won.smarketing.store.controller; + +import com.won.smarketing.common.dto.ApiResponse; +import com.won.smarketing.store.dto.StoreCreateRequest; +import com.won.smarketing.store.dto.StoreCreateResponse; +import com.won.smarketing.store.dto.StoreResponse; +import com.won.smarketing.store.dto.StoreUpdateRequest; +import com.won.smarketing.store.service.StoreService; +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 org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import jakarta.validation.Valid; + +/** + * 매장 관리를 위한 REST API 컨트롤러 + * 매장 등록, 조회, 수정 기능 제공 + */ +@Tag(name = "매장 관리", description = "매장 정보 관리 API") +@RestController +@RequestMapping("/api/store") +@RequiredArgsConstructor +public class StoreController { + + private final StoreService storeService; + + /** + * 매장 정보 등록 + * + * @param request 매장 등록 요청 정보 + * @return 등록된 매장 정보 + */ + @Operation(summary = "매장 등록", description = "새로운 매장 정보를 등록합니다.") + @PostMapping("/register") + public ResponseEntity> register(@Valid @RequestBody StoreCreateRequest request) { + StoreCreateResponse response = storeService.register(request); + return ResponseEntity.ok(ApiResponse.success(response, "매장이 성공적으로 등록되었습니다.")); + } + + /** + * 매장 정보 조회 + * + * //@param userId 조회할 매장 ID + * @return 매장 정보 + */ + @Operation(summary = "매장 조회", description = "유저 ID로 매장 정보를 조회합니다.") + @GetMapping + public ResponseEntity> getStore( +// @Parameter(description = "유저 ID", required = true) +// @RequestParam String userId + ) { + StoreResponse response = storeService.getStore(); + return ResponseEntity.ok(ApiResponse.success(response)); + } + + /** + * 매장 정보 수정 + * + * //@param storeId 수정할 매장 ID + * @param request 매장 수정 요청 정보 + * @return 수정된 매장 정보 + */ + @Operation(summary = "매장 수정", description = "매장 정보를 수정합니다.") + @PutMapping() + public ResponseEntity> updateStore( + @Parameter(description = "매장 ID", required = true) + // @PathVariable Long storeId, + @Valid @RequestBody StoreUpdateRequest request) { + StoreResponse response = storeService.updateStore(request); + return ResponseEntity.ok(ApiResponse.success(response, "매장 정보가 성공적으로 수정되었습니다.")); + } +} diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/ImageUploadRequest.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/ImageUploadRequest.java new file mode 100644 index 0000000..8a12e4a --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/ImageUploadRequest.java @@ -0,0 +1,25 @@ +// store/src/main/java/com/won/smarketing/store/dto/ImageUploadRequest.java +package com.won.smarketing.store.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.web.multipart.MultipartFile; + +import jakarta.validation.constraints.NotNull; + +/** + * 이미지 업로드 요청 DTO + * 이미지 파일 업로드 시 필요한 정보를 전달합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "이미지 업로드 요청") +public class ImageUploadRequest { + + @Schema(description = "업로드할 이미지 파일", required = true) + @NotNull(message = "이미지 파일은 필수입니다") + private MultipartFile file; +} \ No newline at end of file diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/ImageUploadResponse.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/ImageUploadResponse.java new file mode 100644 index 0000000..e937101 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/ImageUploadResponse.java @@ -0,0 +1,37 @@ +// store/src/main/java/com/won/smarketing/store/dto/ImageUploadResponse.java +package com.won.smarketing.store.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 이미지 업로드 응답 DTO + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "이미지 업로드 응답") +public class ImageUploadResponse { + + @Schema(description = "업로드된 이미지 URL", example = "https://storage.blob.core.windows.net/menu-images/menu_123_20241201_143000_abc12345.jpg") + private String imageUrl; + + @Schema(description = "원본 파일명", example = "americano.jpg") + private String originalFileName; + + @Schema(description = "저장된 파일명", example = "menu_123_20241201_143000_abc12345.jpg") + private String savedFileName; + + @Schema(description = "파일 크기 (바이트)", example = "1024000") + private Long fileSize; + + @Schema(description = "업로드 성공 여부", example = "true") + private boolean success; + + @Schema(description = "메시지", example = "이미지 업로드가 완료되었습니다.") + private String message; +} \ No newline at end of file diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuCreateRequest.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuCreateRequest.java new file mode 100644 index 0000000..28dcb5c --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuCreateRequest.java @@ -0,0 +1,45 @@ +package com.won.smarketing.store.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 메뉴 등록 요청 DTO + * 메뉴 등록 시 필요한 정보를 전달합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "메뉴 등록 요청") +public class MenuCreateRequest { + + @Schema(description = "매장 ID", example = "1", required = true) + @NotNull(message = "매장 ID는 필수입니다") + private Long storeId; + + @Schema(description = "메뉴명", example = "아메리카노", required = true) + @NotBlank(message = "메뉴명은 필수입니다") + @Size(max = 100, message = "메뉴명은 100자 이하여야 합니다") + private String menuName; + + @Schema(description = "카테고리", example = "커피") + @Size(max = 50, message = "카테고리는 50자 이하여야 합니다") + private String category; + + @Schema(description = "가격", example = "4500") + @Min(value = 0, message = "가격은 0원 이상이어야 합니다") + private Integer price; + + @Schema(description = "메뉴 설명", example = "진한 맛의 아메리카노") + @Size(max = 500, message = "메뉴 설명은 500자 이하여야 합니다") + private String description; +} + + + diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuResponse.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuResponse.java new file mode 100644 index 0000000..aa9f642 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuResponse.java @@ -0,0 +1,49 @@ +package com.won.smarketing.store.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; + +/** + * 메뉴 응답 DTO + * 메뉴 정보를 클라이언트에게 전달합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "메뉴 응답") +public class MenuResponse { + + @Schema(description = "메뉴 ID", example = "1") + private Long menuId; + + @Schema(description = "매장 ID", example = "1") + private Long storeId; + + @Schema(description = "메뉴명", example = "아메리카노") + private String menuName; + + @Schema(description = "카테고리", example = "커피") + private String category; + + @Schema(description = "가격", example = "4500") + private Integer price; + + @Schema(description = "메뉴 설명", example = "진한 맛의 아메리카노") + private String description; + + @Schema(description = "이미지 URL", example = "https://example.com/americano.jpg") + private String image; + + @Schema(description = "등록일시", example = "2024-01-15T10:30:00") + private LocalDateTime createdAt; + + @Schema(description = "수정일시", example = "2024-01-15T10:30:00") + private LocalDateTime updatedAt; +} + diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java new file mode 100644 index 0000000..4df4894 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java @@ -0,0 +1,38 @@ +package com.won.smarketing.store.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Size; +import org.springframework.web.multipart.MultipartFile; + +/** + * 메뉴 수정 요청 DTO + * 메뉴 정보 수정 시 필요한 정보를 담는 데이터 전송 객체 + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "메뉴 수정 요청 정보") +public class MenuUpdateRequest { + + @Schema(description = "메뉴명", example = "아메리카노") + @Size(max = 200, message = "메뉴명은 200자 이하여야 합니다.") + private String menuName; + + @Schema(description = "메뉴 카테고리", example = "커피") + @Size(max = 100, message = "카테고리는 100자 이하여야 합니다.") + private String category; + + @Schema(description = "가격", example = "4500") + @Min(value = 0, message = "가격은 0 이상이어야 합니다.") + private Integer price; + + @Schema(description = "메뉴 설명", example = "진한 원두의 깊은 맛") + private String description; +} diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/SalesResponse.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/SalesResponse.java new file mode 100644 index 0000000..dc1e47c --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/SalesResponse.java @@ -0,0 +1,41 @@ +package com.won.smarketing.store.dto; + +import com.won.smarketing.store.entity.Sales; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.util.List; + +/** + * 매출 응답 DTO + * 매출 정보를 클라이언트에게 전달합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "매출 응답") +public class SalesResponse { + + @Schema(description = "오늘 매출", example = "150000") + private BigDecimal todaySales; + + @Schema(description = "월간 매출", example = "4500000") + private BigDecimal monthSales; + + @Schema(description = "전일 대비 매출 변화", example = "25000") + private BigDecimal previousDayComparison; + + @Schema(description = "전일 대비 매출 변화율 (%)", example = "15.5") + private BigDecimal previousDayChangeRate; + + @Schema(description = "목표 매출 대비 달성율 (%)", example = "85.2") + private BigDecimal goalAchievementRate; + + @Schema(description = "일년 동안의 매출액") + private List yearSales; +} diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreCreateRequest.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreCreateRequest.java new file mode 100644 index 0000000..ebb72c6 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreCreateRequest.java @@ -0,0 +1,62 @@ +package com.won.smarketing.store.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 매장 등록 요청 DTO + * 매장 등록 시 필요한 정보를 전달합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "매장 등록 요청") +public class StoreCreateRequest { + + @Schema(description = "매장명", example = "맛있는 카페", required = true) + @NotBlank(message = "매장명은 필수입니다") + @Size(max = 100, message = "매장명은 100자 이하여야 합니다") + private String storeName; + + @Schema(description = "업종", example = "카페") + @Size(max = 50, message = "업종은 50자 이하여야 합니다") + private String businessType; + + @Schema(description = "주소", example = "서울시 강남구 테헤란로 123", required = true) + @NotBlank(message = "주소는 필수입니다") + @Size(max = 200, message = "주소는 200자 이하여야 합니다") + private String address; + + @Schema(description = "전화번호", example = "02-1234-5678") + @Size(max = 20, message = "전화번호는 20자 이하여야 합니다") + private String phoneNumber; + + @Schema(description = "영업시간", example = "09:00 - 22:00") + @Size(max = 100, message = "영업시간은 100자 이하여야 합니다") + private String businessHours; + + @Schema(description = "휴무일", example = "매주 일요일") + @Size(max = 100, message = "휴무일은 100자 이하여야 합니다") + private String closedDays; + + @Schema(description = "좌석 수", example = "20") + private Integer seatCount; + + @Schema(description = "SNS 계정 정보", example = "인스타그램: @mystore") + @Size(max = 500, message = "SNS 계정 정보는 500자 이하여야 합니다") + private String instaAccounts; + + @Size(max = 500, message = "SNS 계정 정보는 500자 이하여야 합니다") + @Schema(description = "블로그 계정 정보", example = "블로그: mystore") + private String blogAccounts; + + @Schema(description = "매장 설명", example = "따뜻한 분위기의 동네 카페입니다.") + @Size(max = 1000, message = "매장 설명은 1000자 이하여야 합니다") + private String description; +} + + diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreCreateResponse.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreCreateResponse.java new file mode 100644 index 0000000..bca1adf --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreCreateResponse.java @@ -0,0 +1,56 @@ +package com.won.smarketing.store.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 매장 응답 DTO + * 매장 정보를 클라이언트에게 전달합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "매장 응답") +public class StoreCreateResponse { + + @Schema(description = "매장 ID", example = "1") + private Long storeId; + +// @Schema(description = "매장명", example = "맛있는 카페") +// private String storeName; +// +// @Schema(description = "업종", example = "카페") +// private String businessType; +// +// @Schema(description = "주소", example = "서울시 강남구 테헤란로 123") +// private String address; +// +// @Schema(description = "전화번호", example = "02-1234-5678") +// private String phoneNumber; +// +// @Schema(description = "영업시간", example = "09:00 - 22:00") +// private String businessHours; +// +// @Schema(description = "휴무일", example = "매주 일요일") +// private String closedDays; +// +// @Schema(description = "좌석 수", example = "20") +// private Integer seatCount; +// +// @Schema(description = "SNS 계정 정보", example = "인스타그램: @mystore") +// private String snsAccounts; +// +// @Schema(description = "매장 설명", example = "따뜻한 분위기의 동네 카페입니다.") +// private String description; +// +// @Schema(description = "등록일시", example = "2024-01-15T10:30:00") +// private LocalDateTime createdAt; +// +// @Schema(description = "수정일시", example = "2024-01-15T10:30:00") +// private LocalDateTime updatedAt; +} + diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreResponse.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreResponse.java new file mode 100644 index 0000000..9c0bce0 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreResponse.java @@ -0,0 +1,65 @@ +package com.won.smarketing.store.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.Column; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 매장 응답 DTO + * 매장 정보를 클라이언트에게 전달합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Schema(description = "매장 응답") +public class StoreResponse { + + @Schema(description = "매장 ID", example = "1") + private Long storeId; + + @Schema(description = "매장명", example = "맛있는 카페") + private String storeName; + + @Schema(description = "업종", example = "카페") + private String businessType; + + @Schema(description = "가게 사진") + private String storeImage; + + @Schema(description = "주소", example = "서울시 강남구 테헤란로 123") + private String address; + + @Schema(description = "전화번호", example = "02-1234-5678") + private String phoneNumber; + + @Schema(description = "영업시간", example = "09:00 - 22:00") + private String businessHours; + + @Schema(description = "휴무일", example = "매주 일요일") + private String closedDays; + + @Schema(description = "좌석 수", example = "20") + private Integer seatCount; + + @Schema(description = "블로그 계정 정보", example = "블로그: mystore") + private String blogAccounts; + + @Schema(description = "인스타 계정 정보", example = "인스타그램: @mystore") + private String instaAccounts; + + @Schema(description = "매장 설명", example = "따뜻한 분위기의 동네 카페입니다.") + private String description; + + @Schema(description = "등록일시", example = "2024-01-15T10:30:00") + private LocalDateTime createdAt; + + @Schema(description = "수정일시", example = "2024-01-15T10:30:00") + private LocalDateTime updatedAt; +} + diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreUpdateRequest.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreUpdateRequest.java new file mode 100644 index 0000000..1d235b7 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/StoreUpdateRequest.java @@ -0,0 +1,59 @@ +package com.won.smarketing.store.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 매장 수정 요청 DTO + * 매장 정보 수정 시 필요한 정보를 전달합니다. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "매장 수정 요청") +public class StoreUpdateRequest { + + @Schema(description = "매장명", example = "맛있는 카페") + @Size(max = 100, message = "매장명은 100자 이하여야 합니다") + private String storeName; + + @Schema(description = "업종", example = "카페") + @Size(max = 50, message = "업종은 50자 이하여야 합니다") + private String businessType; + + @Schema(description = "주소", example = "서울시 강남구 테헤란로 123") + @Size(max = 200, message = "주소는 200자 이하여야 합니다") + private String address; + + @Schema(description = "전화번호", example = "02-1234-5678") + @Size(max = 20, message = "전화번호는 20자 이하여야 합니다") + private String phoneNumber; + + @Schema(description = "영업시간", example = "09:00 - 22:00") + @Size(max = 100, message = "영업시간은 100자 이하여야 합니다") + private String businessHours; + + @Schema(description = "휴무일", example = "매주 일요일") + @Size(max = 100, message = "휴무일은 100자 이하여야 합니다") + private String closedDays; + + @Schema(description = "좌석 수", example = "20") + private Integer seatCount; + + @Schema(description = "인스타 계정 정보", example = "인스타그램: @mystore") + @Size(max = 500, message = "인스타 계정 정보는 500자 이하여야 합니다") + private String instaAccounts; + + @Schema(description = "블로그 계정 정보", example = "블로그: mystore") + @Size(max = 500, message = "SNS 계정 정보는 500자 이하여야 합니다") + private String blogAccounts; + + @Schema(description = "매장 설명", example = "따뜻한 분위기의 동네 카페입니다.") + @Size(max = 1000, message = "매장 설명은 1000자 이하여야 합니다") + private String description; +} + + diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/entity/Menu.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/entity/Menu.java new file mode 100644 index 0000000..cef5bb6 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/entity/Menu.java @@ -0,0 +1,90 @@ +package com.won.smarketing.store.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +/** + * 메뉴 엔티티 + * 매장의 메뉴 정보를 관리 + */ +@Entity +@Table(name = "menus") +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@EntityListeners(AuditingEntityListener.class) +public class Menu { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "menu_id") + private Long menuId; + + @Column(name = "store_id", nullable = false) + private Long storeId; + + @Column(name = "menu_name", nullable = false, length = 100) + private String menuName; + + @Column(name = "category", length = 50) + private String category; + + @Column(name = "price", nullable = false) + private Integer price; + + @Column(name = "description", length = 500) + private String description; + + @Column(name = "image_url", length = 500) + private String image; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + /** + * 메뉴 정보 업데이트 + * + * @param menuName 메뉴명 + * @param category 카테고리 + * @param price 가격 + * @param description 설명 + */ + public void updateMenu(String menuName, String category, Integer price, + String description) { + if (menuName != null && !menuName.trim().isEmpty()) { + this.menuName = menuName; + } + if (category != null && !category.trim().isEmpty()) { + this.category = category; + } + if (price != null && price > 0) { + this.price = price; + } + this.description = description; + } + + /** + * 메뉴 이미지 URL 업데이트 + * + * @param imageUrl 새로운 이미지 URL + */ + public void updateImage(String imageUrl) { + this.image = imageUrl; + this.updatedAt = LocalDateTime.now(); + } + +} diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/entity/Sales.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/entity/Sales.java new file mode 100644 index 0000000..91e74c7 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/entity/Sales.java @@ -0,0 +1,62 @@ +package com.won.smarketing.store.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 매출 정보를 나타내는 엔티티 + * 일별 매출 데이터 저장 + */ +@Entity +@Table(name = "sales") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Sales { + + /** + * 매출 고유 식별자 + */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * 매장 ID + */ + @Column(name = "store_id", nullable = false) + private Long storeId; + + /** + * 매출 날짜 + */ + @Column(name = "sales_date", nullable = false) + private LocalDate salesDate; + + /** + * 매출 금액 + */ + @Column(name = "sales_amount", nullable = false, precision = 15, scale = 2) + private BigDecimal salesAmount; + + /** + * 매출 등록 시각 + */ + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + /** + * 엔티티 저장 전 실행되는 메서드 + * 생성 시각을 현재 시각으로 설정 + */ + @PrePersist + protected void onCreate() { + createdAt = LocalDateTime.now(); + } +} + diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/entity/Store.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/entity/Store.java new file mode 100644 index 0000000..2c68120 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/entity/Store.java @@ -0,0 +1,121 @@ +package com.won.smarketing.store.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; +import java.time.LocalTime; + +/** + * 매장 엔티티 + * 매장의 기본 정보와 운영 정보를 관리 + */ +@Entity +@Table(name = "stores") +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@EntityListeners(AuditingEntityListener.class) +public class Store { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "store_id") + private Long id; + + @Column(name = "user_id", nullable = false) + private String userId; + + @Column(name = "store_name", nullable = false, length = 100) + private String storeName; + + @Column(name = "business_type", length = 50) + private String businessType; + + @Column(name = "address", nullable = false, length = 200) + private String address; + + @Column(name = "phone_number", length = 20) + private String phoneNumber; + + @Column(name = "business_hours", length = 100) + private String businessHours; + + @Column(name = "closed_days", length = 100) + private String closedDays; + + @Column(name = "seat_count") + private Integer seatCount; + + @Column(name = "insta_accounts", length = 500) + private String instaAccounts; + + @Column(name = "blog_accounts", length = 500) + private String blogAccounts; + + @Column(name = "description", length = 1000) + private String description; + + @Column(name = "store_image", length = 1000) + private String storeImage; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + /** + * 매장 정보 업데이트 + * + * @param storeName 매장명 + * @param businessType 업종 + * @param address 주소 + * @param phoneNumber 전화번호 + * @param businessHours 영업시간 + * @param closedDays 휴무일 + * @param seatCount 좌석 수 + * @param instaAccounts SNS 계정 정보 +* @param blogAccounts SNS 계정 정보 + * @param description 설명 + */ + public void updateStore(String storeName, String businessType, String address, + String phoneNumber, String businessHours, String closedDays, + Integer seatCount, String instaAccounts, String blogAccounts, String description) { + if (storeName != null && !storeName.trim().isEmpty()) { + this.storeName = storeName; + } + if (businessType != null && !businessType.trim().isEmpty()) { + this.businessType = businessType; + } + if (address != null && !address.trim().isEmpty()) { + this.address = address; + } + this.phoneNumber = phoneNumber; + this.businessHours = businessHours; + this.closedDays = closedDays; + this.seatCount = seatCount; + this.instaAccounts = instaAccounts; + this.blogAccounts = blogAccounts; + this.description = description; + } + + /** + * 메뉴 이미지 URL 업데이트 + * + * @param imageUrl 새로운 이미지 URL + */ + public void updateImage(String imageUrl) { + this.storeImage = imageUrl; + this.updatedAt = LocalDateTime.now(); + } +} diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/repository/MenuRepository.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/repository/MenuRepository.java new file mode 100644 index 0000000..81d8640 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/repository/MenuRepository.java @@ -0,0 +1,31 @@ +package com.won.smarketing.store.repository; + +import com.won.smarketing.store.entity.Menu; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * 메뉴 정보 데이터 접근을 위한 Repository + * JPA를 사용한 메뉴 CRUD 작업 처리 + */ +@Repository +public interface MenuRepository extends JpaRepository { +// /** +// * 전체 메뉴 조회 (메뉴명 오름차순) +// * +// * @return 메뉴 목록 +// */ +// List findAllByOrderByMenuNameAsc(Long ); + + /** + * 매장별 메뉴 조회 + * + * @param storeId 매장 ID + * @return 메뉴 목록 + */ + List findByStoreId(Long storeId); +} diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/repository/SalesRepository.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/repository/SalesRepository.java new file mode 100644 index 0000000..5fec0c2 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/repository/SalesRepository.java @@ -0,0 +1,85 @@ +package com.won.smarketing.store.repository; + +import com.won.smarketing.store.entity.Sales; +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; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.Date; +import java.util.List; +import java.util.Map; + +/** + * 매출 정보 데이터 접근을 위한 Repository + * JPA를 사용한 매출 조회 작업 처리 + */ +@Repository +public interface SalesRepository extends JpaRepository { + + /** + * 매장의 특정 날짜 매출 조회 + * + * @param storeId 매장 ID + * @param salesDate 매출 날짜 + * @return 해당 날짜 매출 목록 + */ + List findByStoreIdAndSalesDate(Long storeId, LocalDate salesDate); + + /** + * 매장의 특정 기간 매출 조회 + * + * @param storeId 매장 ID + * @param startDate 시작 날짜 + * @param endDate 종료 날짜 + * @return 해당 기간 매출 목록 + */ + List findByStoreIdAndSalesDateBetween(Long storeId, LocalDate startDate, LocalDate endDate); + + /** + * 매장의 오늘 매출 조회 (네이티브 쿼리) + * + * @param storeId 매장 ID + * @return 오늘 매출 + */ + @Query(value = "SELECT COALESCE(SUM(sales_amount), 0) FROM sales WHERE store_id = :storeId AND sales_date = CURRENT_DATE", nativeQuery = true) + BigDecimal findTodaySalesByStoreIdNative(@Param("storeId") Long storeId); + + /** + * 매장의 어제 매출 조회 (네이티브 쿼리) + * + * @param storeId 매장 ID + * @return 어제 매출 + */ + @Query(value = "SELECT COALESCE(SUM(sales_amount), 0) FROM sales WHERE store_id = :storeId AND sales_date = CURRENT_DATE - INTERVAL '1 day'", nativeQuery = true) + BigDecimal findYesterdaySalesByStoreIdNative(@Param("storeId") Long storeId); + + /** + * 매장의 이번 달 매출 조회 (네이티브 쿼리) + * + * @param storeId 매장 ID + * @return 이번 달 매출 + */ + @Query(value = "SELECT COALESCE(SUM(sales_amount), 0) FROM sales WHERE store_id = :storeId " + + "AND EXTRACT(YEAR FROM sales_date) = EXTRACT(YEAR FROM CURRENT_DATE) " + + "AND EXTRACT(MONTH FROM sales_date) = EXTRACT(MONTH FROM CURRENT_DATE)", nativeQuery = true) + BigDecimal findMonthSalesByStoreIdNative(@Param("storeId") Long storeId); + + /** + * 매장의 최근 365일 매출 데이터 조회 (날짜와 함께) + * + * @param storeId 매장 ID + * @return 최근 365일 매출 데이터 (날짜 오름차순) + */ + @Query("SELECT s FROM Sales s " + + "WHERE s.storeId = :storeId " + + "AND s.salesDate >= :startDate " + + "AND s.salesDate <= :endDate " + + "ORDER BY s.salesDate ASC") + List findSalesDataLast365Days( + @Param("storeId") Long storeId, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate); +} \ No newline at end of file diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/repository/StoreRepository.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/repository/StoreRepository.java new file mode 100644 index 0000000..4fbbcea --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/repository/StoreRepository.java @@ -0,0 +1,42 @@ +package com.won.smarketing.store.repository; + +import com.won.smarketing.store.entity.Store; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * 매장 정보 데이터 접근을 위한 Repository + * JPA를 사용한 매장 CRUD 작업 처리 + */ +@Repository +public interface StoreRepository extends JpaRepository { + + /** + * 회원 ID로 매장 조회 + * + * @param userId 회원 ID + * @return 매장 정보 (Optional) + */ + Optional findByUserId(String userId); + + /** + * 회원의 매장 존재 여부 확인 + * + * @param userId 회원 ID + * @return 존재 여부 + */ + boolean existsByUserId(String userId); + + /** + * 매장명으로 매장 조회 + * + * @param storeName 매장명 + * @return 매장 목록 + */ + Optional findByStoreName(String storeName); +} + + + diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/service/BlobStorageService.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/BlobStorageService.java new file mode 100644 index 0000000..83764a3 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/BlobStorageService.java @@ -0,0 +1,55 @@ +// store/src/main/java/com/won/smarketing/store/service/BlobStorageService.java +package com.won.smarketing.store.service; + +import com.won.smarketing.store.dto.MenuResponse; +import com.won.smarketing.store.dto.StoreResponse; +import org.springframework.web.multipart.MultipartFile; + +/** + * Azure Blob Storage 서비스 인터페이스 + * 파일 업로드, 다운로드, 삭제 기능 정의 + */ +public interface BlobStorageService { + + /** + * 이미지 파일 업로드 + * + * @param file 업로드할 파일 + * @param containerName 컨테이너 이름 + * @param fileName 저장할 파일명 + * @return 업로드된 파일의 URL + */ + String uploadImage(MultipartFile file, String containerName, String fileName); + + /** + * 메뉴 이미지 업로드 (편의 메서드) + * + * @param file 업로드할 파일 + * @return 업로드된 파일의 URL + */ + MenuResponse uploadMenuImage(MultipartFile file, Long menuId); + + /** + * 매장 이미지 업로드 (편의 메서드) + * + * @param file 업로드할 파일 + * @param storeId 매장 ID + * @return 업로드된 파일의 URL + */ + StoreResponse uploadStoreImage(MultipartFile file, Long storeId); + + /** + * 파일 삭제 + * + * @param fileUrl 삭제할 파일의 URL + * @return 삭제 성공 여부 + */ + //boolean deleteFile(String fileUrl); + + /** + * 컨테이너 존재 여부 확인 및 생성 + * + * @param containerName 컨테이너 이름 + */ + void ensureContainerExists(String containerName); +} diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/service/BlobStorageServiceImpl.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/BlobStorageServiceImpl.java new file mode 100644 index 0000000..fde8c91 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/BlobStorageServiceImpl.java @@ -0,0 +1,332 @@ +// store/src/main/java/com/won/smarketing/store/service/BlobStorageServiceImpl.java +package com.won.smarketing.store.service; + +import com.azure.core.util.BinaryData; +import com.azure.storage.blob.BlobClient; +import com.azure.storage.blob.BlobContainerClient; +import com.azure.storage.blob.BlobServiceClient; +import com.azure.storage.blob.models.BlobHttpHeaders; +import com.azure.storage.blob.models.PublicAccessType; +import com.won.smarketing.common.exception.BusinessException; +import com.won.smarketing.common.exception.ErrorCode; +import com.won.smarketing.store.dto.MenuResponse; +import com.won.smarketing.store.dto.StoreResponse; +import com.won.smarketing.store.entity.Menu; +import com.won.smarketing.store.entity.Store; +import com.won.smarketing.store.repository.MenuRepository; +import com.won.smarketing.store.repository.StoreRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Arrays; +import java.util.List; +import java.util.UUID; + +/** + * Azure Blob Storage 서비스 구현체 + * 이미지 파일 업로드, 삭제 기능 구현 + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class BlobStorageServiceImpl implements BlobStorageService { + + private final BlobServiceClient blobServiceClient; + private final MenuRepository menuRepository; + private final StoreRepository storeRepository; + + @Value("${azure.storage.container.menu-images:menu-images}") + private String menuImageContainer; + + @Value("${azure.storage.container.store-images:store-images}") + private String storeImageContainer; + + @Value("${azure.storage.max-file-size:10485760}") // 10MB + private long maxFileSize; + + // 허용되는 이미지 확장자 + private static final List ALLOWED_EXTENSIONS = Arrays.asList( + "jpg", "jpeg", "png", "gif", "bmp", "webp" + ); + + // 허용되는 MIME 타입 + private static final List ALLOWED_MIME_TYPES = Arrays.asList( + "image/jpeg", "image/png", "image/gif", "image/bmp", "image/webp" + ); + + /** + * 이미지 파일 업로드 + * + * @param file 업로드할 파일 + * @param containerName 컨테이너 이름 + * @param fileName 저장할 파일명 + * @return 업로드된 파일의 URL + */ + @Override + public String uploadImage(MultipartFile file, String containerName, String fileName) { + // 파일 유효성 검증 + validateImageFile(file); + + try { + // 컨테이너 존재 확인 및 생성 + ensureContainerExists(containerName); + + // Blob 클라이언트 생성 + BlobContainerClient containerClient = blobServiceClient.getBlobContainerClient(containerName); + BlobClient blobClient = containerClient.getBlobClient(fileName); + + // 파일 업로드 (간단한 방식) + BinaryData binaryData = BinaryData.fromBytes(file.getBytes()); + + // 파일 업로드 실행 (덮어쓰기 허용) + blobClient.upload(binaryData, true); + + // Content-Type 설정 + BlobHttpHeaders headers = new BlobHttpHeaders().setContentType(file.getContentType()); + blobClient.setHttpHeaders(headers); + + String fileUrl = blobClient.getBlobUrl(); + log.info("이미지 업로드 성공: {}", fileUrl); + + return fileUrl; + + } catch (IOException e) { + log.error("이미지 업로드 실패 - 파일 읽기 오류: {}", e.getMessage()); + throw new BusinessException(ErrorCode.FILE_UPLOAD_FAILED); + } catch (Exception e) { + log.error("이미지 업로드 실패: {}", e.getMessage()); + throw new BusinessException(ErrorCode.FILE_UPLOAD_FAILED); + } + } + + /** + * 메뉴 이미지 업로드 + * + * @param file 업로드할 파일 + * @return 업로드된 파일의 URL + */ + @Override + public MenuResponse uploadMenuImage(MultipartFile file, Long menuId) { + String fileName = generateMenuImageFileName(file.getOriginalFilename()); + + //메뉴id로 데이터를 찾아서 + Menu menu = menuRepository.findById(menuId) + .orElseThrow(() -> new BusinessException(ErrorCode.MENU_NOT_FOUND)); + + // 기존 이미지가 있다면 삭제 + if (menu.getImage() != null && !menu.getImage().isEmpty()) { + deleteFile(menu.getImage()); + } + + //새로 올리고 + String fileUrl = uploadImage(file, menuImageContainer, fileName); + + //메뉴에 다시 저장 + menu.updateImage(fileUrl); + menuRepository.save(menu); + + return MenuResponse.builder() + .menuId(menu.getMenuId()) + .menuName(menu.getMenuName()) + .category(menu.getCategory()) + .price(menu.getPrice()) + .image(fileUrl) + .description(menu.getDescription()) + .createdAt(menu.getCreatedAt()) + .updatedAt(menu.getUpdatedAt()) + .build(); + } + + /** + * 매장 이미지 업로드 + * + * @param file 업로드할 파일 + * @param storeId 매장 ID + * @return 업로드된 파일의 URL + */ + @Override + public StoreResponse uploadStoreImage(MultipartFile file, Long storeId) { + String fileName = generateStoreImageFileName(storeId, file.getOriginalFilename()); + + Store store = storeRepository.findById(storeId) + .orElseThrow(() -> new BusinessException(ErrorCode.STORE_NOT_FOUND)); + + // 기존 이미지가 있다면 삭제 + if (store.getStoreImage() != null && !store.getStoreImage().isEmpty()) { + deleteFile(store.getStoreImage()); + } + //새로 올리고 + String fileUrl = uploadImage(file, storeImageContainer, fileName); + + store.updateImage(fileUrl); + storeRepository.save(store); + + return StoreResponse.builder() + .storeId(store.getId()) + .storeName(store.getStoreName()) + .businessType(store.getBusinessType()) + .address(store.getAddress()) + .phoneNumber(store.getPhoneNumber()) + .businessHours(store.getBusinessHours()) + .closedDays(store.getClosedDays()) + .seatCount(store.getSeatCount()) + .blogAccounts(store.getBlogAccounts()) + .instaAccounts(store.getInstaAccounts()) + .storeImage(fileUrl) + .description(store.getDescription()) + .createdAt(store.getCreatedAt()) + .updatedAt(store.getUpdatedAt()) + .build(); + } + + /** + * 파일 삭제 + * + * @param fileUrl 삭제할 파일의 URL + */ +// @Override + public void deleteFile(String fileUrl) { + try { + // URL에서 컨테이너명과 파일명 추출 + String[] urlParts = extractContainerAndFileName(fileUrl); + String containerName = urlParts[0]; + String fileName = urlParts[1]; + + BlobContainerClient containerClient = blobServiceClient.getBlobContainerClient(containerName); + BlobClient blobClient = containerClient.getBlobClient(fileName); + + boolean deleted = blobClient.deleteIfExists(); + + if (deleted) { + log.info("파일 삭제 성공: {}", fileUrl); + } else { + log.warn("파일이 존재하지 않음: {}", fileUrl); + } + + } catch (Exception e) { + log.error("파일 삭제 실패: {}", e.getMessage()); + } + } + + /** + * 컨테이너 존재 여부 확인 및 생성 + * + * @param containerName 컨테이너 이름 + */ + @Override + public void ensureContainerExists(String containerName) { + try { + BlobContainerClient containerClient = blobServiceClient.getBlobContainerClient(containerName); + + if (!containerClient.exists()) { + containerClient.createWithResponse(null, PublicAccessType.BLOB, null, null); + log.info("컨테이너 생성 완료: {}", containerName); + } + + } catch (Exception e) { + log.error("컨테이너 생성 실패: {}", e.getMessage()); + throw new BusinessException(ErrorCode.STORAGE_CONTAINER_ERROR); + } + } + + /** + * 이미지 파일 유효성 검증 + * + * @param file 검증할 파일 + */ + private void validateImageFile(MultipartFile file) { + // 파일 존재 여부 확인 + if (file == null || file.isEmpty()) { + throw new BusinessException(ErrorCode.FILE_NOT_FOUND); + } + + // 파일 크기 확인 + if (file.getSize() > maxFileSize) { + throw new BusinessException(ErrorCode.FILE_SIZE_EXCEEDED); + } + + // 파일 확장자 확인 + String originalFilename = file.getOriginalFilename(); + if (originalFilename == null) { + throw new BusinessException(ErrorCode.INVALID_FILE_NAME); + } + + String extension = getFileExtension(originalFilename).toLowerCase(); + if (!ALLOWED_EXTENSIONS.contains(extension)) { + throw new BusinessException(ErrorCode.INVALID_FILE_EXTENSION); + } + + // MIME 타입 확인 + String contentType = file.getContentType(); + if (contentType == null || !ALLOWED_MIME_TYPES.contains(contentType)) { + throw new BusinessException(ErrorCode.INVALID_FILE_TYPE); + } + } + + /** + * 메뉴 이미지 파일명 생성 + * + * @param originalFilename 원본 파일명 + * @return 생성된 파일명 + */ + private String generateMenuImageFileName(String originalFilename) { + String extension = getFileExtension(originalFilename); + String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss")); + String uuid = UUID.randomUUID().toString().substring(0, 8); + + return String.format("menu_%s_%s.%s", timestamp, uuid, extension); + } + + /** + * 매장 이미지 파일명 생성 + * + * @param storeId 매장 ID + * @param originalFilename 원본 파일명 + * @return 생성된 파일명 + */ + private String generateStoreImageFileName(Long storeId, String originalFilename) { + String extension = getFileExtension(originalFilename); + String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd_HHmmss")); + String uuid = UUID.randomUUID().toString().substring(0, 8); + + return String.format("store_%d_%s_%s.%s", storeId, timestamp, uuid, extension); + } + + /** + * 파일 확장자 추출 + * + * @param filename 파일명 + * @return 확장자 + */ + private String getFileExtension(String filename) { + int lastDotIndex = filename.lastIndexOf('.'); + if (lastDotIndex == -1) { + return ""; + } + return filename.substring(lastDotIndex + 1); + } + + /** + * URL에서 컨테이너명과 파일명 추출 + * + * @param fileUrl 파일 URL + * @return [컨테이너명, 파일명] 배열 + */ + private String[] extractContainerAndFileName(String fileUrl) { + // URL 형식: https://accountname.blob.core.windows.net/container/filename + try { + String[] parts = fileUrl.split("/"); + String containerName = parts[parts.length - 2]; + String fileName = parts[parts.length - 1]; + return new String[]{containerName, fileName}; + } catch (Exception e) { + throw new BusinessException(ErrorCode.INVALID_FILE_URL); + } + } +} \ No newline at end of file diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/service/MenuService.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/MenuService.java new file mode 100644 index 0000000..a2e8963 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/MenuService.java @@ -0,0 +1,57 @@ +package com.won.smarketing.store.service; + +import com.won.smarketing.store.dto.ImageUploadResponse; +import com.won.smarketing.store.dto.MenuCreateRequest; +import com.won.smarketing.store.dto.MenuResponse; +import com.won.smarketing.store.dto.MenuUpdateRequest; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +/** + * 메뉴 서비스 인터페이스 + * 메뉴 관리 관련 비즈니스 로직 정의 + */ +public interface MenuService { + + /** + * 메뉴 등록 + * + * @param request 메뉴 등록 요청 정보 + * @return 등록된 메뉴 정보 + */ + MenuResponse register(MenuCreateRequest request); + + /** + * 메뉴 목록 조회 + * + * @param storeId 가게 ID + * @return 메뉴 목록 + */ + List getMenus(Long storeId); + + /** + * 메뉴 정보 수정 + * + * @param menuId 메뉴 ID + * @param request 메뉴 수정 요청 정보 + * @return 수정된 메뉴 정보 + */ + MenuResponse updateMenu(Long menuId, MenuUpdateRequest request); + + /** + * 메뉴 삭제 + * + * @param menuId 메뉴 ID + */ + void deleteMenu(Long menuId); + +// /** +// * 메뉴 이미지 업로드 +// * +// * @param menuId 메뉴 ID +// * @param file 업로드할 이미지 파일 +// * @return 이미지 업로드 결과 +// */ +// ImageUploadResponse uploadMenuImage(Long menuId, MultipartFile file); +} diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/service/MenuServiceImpl.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/MenuServiceImpl.java new file mode 100644 index 0000000..d75efc2 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/MenuServiceImpl.java @@ -0,0 +1,166 @@ +package com.won.smarketing.store.service; + +import com.won.smarketing.common.exception.BusinessException; +import com.won.smarketing.common.exception.ErrorCode; +import com.won.smarketing.store.dto.ImageUploadResponse; +import com.won.smarketing.store.dto.MenuCreateRequest; +import com.won.smarketing.store.dto.MenuResponse; +import com.won.smarketing.store.dto.MenuUpdateRequest; +import com.won.smarketing.store.entity.Menu; +import com.won.smarketing.store.repository.MenuRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * 메뉴 관리 서비스 구현체 + * 메뉴 등록, 조회, 수정, 삭제 기능 구현 + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MenuServiceImpl implements MenuService { + + private final MenuRepository menuRepository; + + /** + * 메뉴 정보 등록 + * + * @param request 메뉴 등록 요청 정보 + * @return 등록된 메뉴 정보 + */ + @Override + @Transactional + public MenuResponse register(MenuCreateRequest request) { + // 메뉴 엔티티 생성 및 저장 + Menu menu = Menu.builder() + .storeId(request.getStoreId()) + .menuName(request.getMenuName()) + .category(request.getCategory()) + .price(request.getPrice()) + .description(request.getDescription()) + .build(); + + Menu savedMenu = menuRepository.save(menu); + return toMenuResponse(savedMenu); + } + + /** + * 메뉴 목록 조회 + * + * @param storeId 가게 ID + * @return 메뉴 목록 + */ + @Override + public List getMenus(Long storeId) { + List menus; + + menus = menuRepository.findByStoreId(storeId); + + return menus.stream() + .map(this::toMenuResponse) + .collect(Collectors.toList()); + } + + /** + * 메뉴 정보 수정 + * + * @param menuId 수정할 메뉴 ID + * @param request 메뉴 수정 요청 정보 + * @return 수정된 메뉴 정보 + */ + @Override + @Transactional + public MenuResponse updateMenu(Long menuId, MenuUpdateRequest request) { + + Menu menu = menuRepository.findById(menuId) + .orElseThrow(() -> new BusinessException(ErrorCode.MENU_NOT_FOUND)); + + // 메뉴 정보 업데이트 + menu.updateMenu( + request.getMenuName(), + request.getCategory(), + request.getPrice(), + request.getDescription() + ); + + Menu updatedMenu = menuRepository.save(menu); + return toMenuResponse(updatedMenu); + } + + /** + * 메뉴 삭제 + * + * @param menuId 삭제할 메뉴 ID + */ + @Override + @Transactional + public void deleteMenu(Long menuId) { + Menu menu = menuRepository.findById(menuId) + .orElseThrow(() -> new BusinessException(ErrorCode.MENU_NOT_FOUND)); + + menuRepository.delete(menu); + } + + /** + * Menu 엔티티를 MenuResponse DTO로 변환 + * + * @param menu Menu 엔티티 + * @return MenuResponse DTO + */ + private MenuResponse toMenuResponse(Menu menu) { + return MenuResponse.builder() + .menuId(menu.getMenuId()) + .menuName(menu.getMenuName()) + .category(menu.getCategory()) + .price(menu.getPrice()) + .description(menu.getDescription()) + .createdAt(menu.getCreatedAt()) + .updatedAt(menu.getUpdatedAt()) + .build(); + } + +// /** +// * 메뉴 이미지 업로드 +// * +// * @param menuId 메뉴 ID +// * @param file 업로드할 이미지 파일 +// * @return 이미지 업로드 결과 +// */ +// @Override +// @Transactional +// public ImageUploadResponse uploadMenuImage(Long menuId, MultipartFile file) { +// // 메뉴 존재 여부 확인 +// Menu menu = menuRepository.findById(menuId) +// .orElseThrow(() -> new BusinessException(ErrorCode.MENU_NOT_FOUND)); +// +// try { +// // 기존 이미지가 있다면 삭제 +// if (menu.getImage() != null && !menu.getImage().isEmpty()) { +// blobStorageService.deleteFile(menu.getImage()); +// } +// +// // 새 이미지 업로드 +// String imageUrl = blobStorageService.uploadMenuImage(file, menuId); +// +// // 메뉴 엔티티의 이미지 URL 업데이트 +// menu.updateImage(imageUrl); +// menuRepository.save(menu); +// +// return ImageUploadResponse.builder() +// .imageUrl(imageUrl) +// .originalFileName(file.getOriginalFilename()) +// .fileSize(file.getSize()) +// .success(true) +// .message("메뉴 이미지 업로드가 완료되었습니다.") +// .build(); +// +// } catch (Exception e) { +// throw new BusinessException(ErrorCode.FILE_UPLOAD_FAILED); +// } +// } +} diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/service/SalesService.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/SalesService.java new file mode 100644 index 0000000..5221f56 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/SalesService.java @@ -0,0 +1,17 @@ +package com.won.smarketing.store.service; + +import com.won.smarketing.store.dto.SalesResponse; + +/** + * 매출 서비스 인터페이스 + * 매출 조회 관련 비즈니스 로직 정의 + */ +public interface SalesService { + + /** + * 매출 정보 조회 + * + * @return 매출 정보 + */ + SalesResponse getSales(Long storeId); +} diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/service/SalesServiceImpl.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/SalesServiceImpl.java new file mode 100644 index 0000000..0fa4848 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/SalesServiceImpl.java @@ -0,0 +1,100 @@ +package com.won.smarketing.store.service; + +import com.won.smarketing.store.dto.SalesResponse; +import com.won.smarketing.store.entity.Sales; +import com.won.smarketing.store.repository.SalesRepository; +import com.won.smarketing.store.repository.StoreRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 매출 관리 서비스 구현체 + * 매출 조회 기능 구현 + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SalesServiceImpl implements SalesService { + + private final SalesRepository salesRepository; + + /** + * 매출 정보 조회 + * + * @return 매출 정보 (오늘, 월간, 전일 대비) + */ + @Override + public SalesResponse getSales(Long storeId) { + // 오늘 매출 계산 + BigDecimal todaySales = calculateSalesByDate(storeId, LocalDate.now()); + + // 이번 달 매출 계산 + BigDecimal monthSales = calculateMonthSales(storeId); + + // 어제 매출 계산 + BigDecimal yesterdaySales = calculateSalesByDate(storeId, LocalDate.now().minusDays(1)); + + // 전일 대비 매출 변화량 계산 + BigDecimal previousDayComparison = todaySales.subtract(yesterdaySales); + + //오늘로부터 1년 전까지의 매출 리스트 + + return SalesResponse.builder() + .todaySales(todaySales) + .monthSales(monthSales) + .yearSales(getSalesAmountListLast365Days(storeId)) + .previousDayComparison(previousDayComparison) + .build(); + } + + /** + * 특정 날짜의 매출 계산 + * + * @param storeId 매장 ID + * @param date 날짜 + * @return 해당 날짜 매출 + */ + private BigDecimal calculateSalesByDate(Long storeId, LocalDate date) { + List salesList = salesRepository.findByStoreIdAndSalesDate(storeId, date); + return salesList.stream() + .map(Sales::getSalesAmount) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } + + /** + * 이번 달 매출 계산 + * + * @param storeId 매장 ID + * @return 이번 달 매출 + */ + private BigDecimal calculateMonthSales(Long storeId) { + LocalDate now = LocalDate.now(); + LocalDate startOfMonth = now.withDayOfMonth(1); + LocalDate endOfMonth = now.withDayOfMonth(now.lengthOfMonth()); + + List salesList = salesRepository.findByStoreIdAndSalesDateBetween(storeId, startOfMonth, endOfMonth); + return salesList.stream() + .map(Sales::getSalesAmount) + .reduce(BigDecimal.ZERO, BigDecimal::add); + } + + /** + * 최근 365일 매출 금액 리스트 조회 + * + * @param storeId 매장 ID + * @return 최근 365일 매출 금액 리스트 + */ + private List getSalesAmountListLast365Days(Long storeId) { + LocalDate endDate = LocalDate.now(); + LocalDate startDate = endDate.minusDays(365); + + // Sales 엔티티 전체를 조회하는 메서드 사용 + return salesRepository.findSalesDataLast365Days(storeId, startDate, endDate); + } +} \ No newline at end of file diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/service/StoreService.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/StoreService.java new file mode 100644 index 0000000..ac72664 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/StoreService.java @@ -0,0 +1,45 @@ +package com.won.smarketing.store.service; + +import com.won.smarketing.store.dto.StoreCreateRequest; +import com.won.smarketing.store.dto.StoreCreateResponse; +import com.won.smarketing.store.dto.StoreResponse; +import com.won.smarketing.store.dto.StoreUpdateRequest; + +/** + * 매장 서비스 인터페이스 + * 매장 관리 관련 비즈니스 로직 정의 + */ +public interface StoreService { + + /** + * 매장 등록 + * + * @param request 매장 등록 요청 정보 + * @return 등록된 매장 정보 + */ + StoreCreateResponse register(StoreCreateRequest request); + + /** + * 매장 정보 조회 (현재 로그인 사용자) + * + * @return 매장 정보 + */ + StoreResponse getMyStore(); + + /** + * 매장 정보 조회 (매장 ID) + * + * //@param userId 매장 ID + * @return 매장 정보 + */ + StoreResponse getStore(); + + /** + * 매장 정보 수정 + * + * //@param storeId 매장 ID + * @param request 매장 수정 요청 정보 + * @return 수정된 매장 정보 + */ + StoreResponse updateStore(StoreUpdateRequest request); +} diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/service/StoreServiceImpl.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/StoreServiceImpl.java new file mode 100644 index 0000000..d8901c0 --- /dev/null +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/StoreServiceImpl.java @@ -0,0 +1,190 @@ +package com.won.smarketing.store.service; + +import com.won.smarketing.common.exception.BusinessException; +import com.won.smarketing.common.exception.ErrorCode; +import com.won.smarketing.store.dto.StoreCreateRequest; +import com.won.smarketing.store.dto.StoreCreateResponse; +import com.won.smarketing.store.dto.StoreResponse; +import com.won.smarketing.store.dto.StoreUpdateRequest; +import com.won.smarketing.store.entity.Store; +import com.won.smarketing.store.repository.StoreRepository; +import jakarta.xml.bind.annotation.XmlType; +import lombok.Builder; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 매장 서비스 구현체 + * 매장 등록, 조회, 수정 기능 구현 + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class StoreServiceImpl implements StoreService { + + private final StoreRepository storeRepository; + + /** + * 매장 등록 + * + * @param request 매장 등록 요청 정보 + * @return 등록된 매장 정보 + */ + @Override + @Transactional + public StoreCreateResponse register(StoreCreateRequest request) { + String memberId = getCurrentUserId(); + // Long memberId = Long.valueOf(currentUserId); // 실제로는 Member ID 조회 필요 + + log.info("매장 등록 시작: {} (회원: {})", request.getStoreName(), memberId); + + // 회원당 하나의 매장만 등록 가능 + if (storeRepository.existsByUserId(memberId)) { + throw new BusinessException(ErrorCode.STORE_ALREADY_EXISTS); + } + + // 매장 엔티티 생성 및 저장 + Store store = Store.builder() + .userId(memberId) + .storeName(request.getStoreName()) + .businessType(request.getBusinessType()) + .address(request.getAddress()) + .phoneNumber(request.getPhoneNumber()) + .businessHours(request.getBusinessHours()) + .closedDays(request.getClosedDays()) + .seatCount(request.getSeatCount()) + .blogAccounts(request.getBlogAccounts()) + .instaAccounts(request.getInstaAccounts()) + .description(request.getDescription()) + .build(); + + Store savedStore = storeRepository.save(store); + log.info("매장 등록 완료: {} (ID: {})", savedStore.getStoreName(), savedStore.getId()); + + return toStoreCreateResponse(savedStore); + } + + /** + * 매장 정보 조회 (현재 로그인 사용자) + * + * @return 매장 정보 + */ + @Override + public StoreResponse getMyStore() { + String memberId = getCurrentUserId(); + // Long memberId = Long.valueOf(currentUserId); + + Store store = storeRepository.findByUserId(memberId) + .orElseThrow(() -> new BusinessException(ErrorCode.STORE_NOT_FOUND)); + + return toStoreResponse(store); + } + + /** + * 매장 정보 조회 (매장 ID) + * + * //@param storeId 매장 ID + * @return 매장 정보 + */ + @Override + public StoreResponse getStore() { + try { + String userId = getCurrentUserId(); + Store store = storeRepository.findByUserId(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.STORE_NOT_FOUND)); + + return toStoreResponse(store); + } catch (NumberFormatException e) { + throw new BusinessException(ErrorCode.INVALID_INPUT_VALUE); + } + } + + /** + * 매장 정보 수정 + * + * //@param storeId 매장 ID + * @param request 매장 수정 요청 정보 + * @return 수정된 매장 정보 + */ + @Override + @Transactional + public StoreResponse updateStore(StoreUpdateRequest request) { + String userId = getCurrentUserId(); + + Store store = storeRepository.findByUserId(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.STORE_NOT_FOUND)); + + // 매장 정보 업데이트 + store.updateStore( + request.getStoreName(), + request.getBusinessType(), + request.getAddress(), + request.getPhoneNumber(), + request.getBusinessHours(), + request.getClosedDays(), + request.getSeatCount(), + request.getInstaAccounts(), + request.getBlogAccounts(), + request.getDescription() + ); + + Store updatedStore = storeRepository.save(store); + log.info("매장 정보 수정 완료: {} (ID: {})", updatedStore.getStoreName(), updatedStore.getId()); + + return toStoreResponse(updatedStore); + } + + /** + * Store 엔티티를 StoreResponse DTO로 변환 + * + * @param store Store 엔티티 + * @return StoreResponse DTO + */ + private StoreResponse toStoreResponse(Store store) { + return StoreResponse.builder() + .storeId(store.getId()) + .storeName(store.getStoreName()) + .businessType(store.getBusinessType()) + .address(store.getAddress()) + .phoneNumber(store.getPhoneNumber()) + .businessHours(store.getBusinessHours()) + .closedDays(store.getClosedDays()) + .seatCount(store.getSeatCount()) + .blogAccounts(store.getBlogAccounts()) + .instaAccounts(store.getInstaAccounts()) + .description(store.getDescription()) + .createdAt(store.getCreatedAt()) + .updatedAt(store.getUpdatedAt()) + .build(); + } + + private StoreCreateResponse toStoreCreateResponse(Store store) { + return StoreCreateResponse.builder() + .storeId(store.getId()) +// .storeName(store.getStoreName()) +// .businessType(store.getBusinessType()) +// .address(store.getAddress()) +// .phoneNumber(store.getPhoneNumber()) +// .businessHours(store.getBusinessHours()) +// .closedDays(store.getClosedDays()) +// .seatCount(store.getSeatCount()) +// .snsAccounts(store.getSnsAccounts()) +// .description(store.getDescription()) +// .createdAt(store.getCreatedAt()) +// .updatedAt(store.getUpdatedAt()) + .build(); + } + + /** + * 현재 로그인된 사용자 ID 조회 + * + * @return 사용자 ID + */ + private String getCurrentUserId() { + return SecurityContextHolder.getContext().getAuthentication().getName(); + } +} diff --git a/smarketing-java/store/src/main/resources/application.yml b/smarketing-java/store/src/main/resources/application.yml new file mode 100644 index 0000000..5a50cbc --- /dev/null +++ b/smarketing-java/store/src/main/resources/application.yml @@ -0,0 +1,48 @@ +server: + port: ${SERVER_PORT:8082} + +spring: + servlet: + multipart: + max-file-size: 10MB + max-request-size: 10MB + enabled: true + application: + name: store-service + datasource: + url: jdbc:postgresql://${POSTGRES_HOST:localhost}:${POSTGRES_PORT:5432}/${POSTGRES_DB:StoreDB} + username: ${POSTGRES_USER:postgres} + password: ${POSTGRES_PASSWORD:postgres} + driver-class-name: org.postgresql.Driver + jpa: + hibernate: + ddl-auto: ${DDL_AUTO:update} + show-sql: ${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:} + +logging: + level: + com.won.smarketing.store: ${LOG_LEVEL:DEBUG} + +jwt: + secret: ${JWT_SECRET:mySecretKeyForJWTTokenGenerationAndValidation123456789} + access-token-validity: ${JWT_ACCESS_VALIDITY:3600000} + refresh-token-validity: ${JWT_REFRESH_VALIDITY:604800000} +# Azure Storage 설정 +azure: + storage: + account-name: ${AZURE_STORAGE_ACCOUNT_NAME:stdigitalgarage02} + account-key: ${AZURE_STORAGE_ACCOUNT_KEY:} + endpoint: ${AZURE_STORAGE_ENDPOINT:https://stdigitalgarage02.blob.core.windows.net} + container: + menu-images: ${AZURE_STORAGE_MENU_CONTAINER:smarketing-menu-images} + store-images: ${AZURE_STORAGE_STORE_CONTAINER:smarketing-store-images} + max-file-size: ${AZURE_STORAGE_MAX_FILE_SIZE:10485760} # 10MB From eb08aed281078eea79879fa7176112400672b381 Mon Sep 17 00:00:00 2001 From: John Hanzu Kim Date: Tue, 17 Jun 2025 10:07:15 +0900 Subject: [PATCH 02/31] Update Jenkinsfile --- smarketing-java/deployment/Jenkinsfile | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/smarketing-java/deployment/Jenkinsfile b/smarketing-java/deployment/Jenkinsfile index b281bd7..f9a348d 100644 --- a/smarketing-java/deployment/Jenkinsfile +++ b/smarketing-java/deployment/Jenkinsfile @@ -49,8 +49,8 @@ podTemplate( az login --service-principal -u \$AZURE_CLIENT_ID -p \$AZURE_CLIENT_SECRET -t \$AZURE_TENANT_ID az account set --subscription 2513dd36-7978-48e3-9a7c-b221d4874f66 - echo "=== AKS 인증정보 가져오기 ===" - az aks get-credentials --resource-group rg-digitalgarage-01 --name aks-digitalgarage-01 --overwrite-existing + echo "=== AKS 인증정보 가져오기 (rg-digitalgarage-02) ===" + az aks get-credentials --resource-group rg-digitalgarage-02 --name aks-digitalgarage-02 --overwrite-existing echo "=== 네임스페이스 생성 ===" kubectl create namespace ${namespace} --dry-run=client -o yaml | kubectl apply -f - @@ -66,6 +66,9 @@ podTemplate( echo "=== 클러스터 상태 확인 ===" kubectl get nodes kubectl get ns ${namespace} + + echo "=== 현재 연결된 클러스터 확인 ===" + kubectl config current-context """ } } @@ -99,7 +102,7 @@ podTemplate( timeout 30 sh -c 'until docker info; do sleep 1; done' """ - // 🔧 ACR Credential을 Jenkins에서 직접 사용 + // ACR Credential을 Jenkins에서 직접 사용 withCredentials([usernamePassword( credentialsId: 'acr-credentials', usernameVariable: 'ACR_USERNAME', @@ -184,6 +187,10 @@ podTemplate( container('azure-cli') { sh """ + echo "=== 현재 연결된 클러스터 재확인 ===" + kubectl config current-context + kubectl cluster-info | head -3 + echo "=== PostgreSQL 서비스 확인 ===" kubectl get svc -n ${namespace} | grep postgresql || echo "PostgreSQL 서비스가 없습니다. 먼저 설치해주세요." From dc0660111efedf904e19c8e549a19fb4314c6fb1 Mon Sep 17 00:00:00 2001 From: OhSeongRak Date: Tue, 17 Jun 2025 10:19:59 +0900 Subject: [PATCH 03/31] =?UTF-8?q?refactor:=20secret=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- smarketing-ai/deployment/manifest/secret.yaml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/smarketing-ai/deployment/manifest/secret.yaml b/smarketing-ai/deployment/manifest/secret.yaml index cf24d0b..d013ead 100644 --- a/smarketing-ai/deployment/manifest/secret.yaml +++ b/smarketing-ai/deployment/manifest/secret.yaml @@ -4,6 +4,7 @@ metadata: name: smarketing-secret namespace: smarketing type: Opaque - -data: - OPENAI_API_KEY: c2stcHJvai1BbjRRX3VTNnNzQkxLU014VXBYTDBPM0lteUJuUjRwNVFTUHZkRnNSeXpFWGE0M21ISnhBcUkzNGZQOEduV2ZxclBpQ29VZ2pmbFQzQmxia0ZKZklMUGVqUFFIem9ZYzU4Yzc4UFkzeUo0dkowTVlfNGMzNV82dFlQUlkzTDBIODAwWWVvMnpaTmx6V3hXNk1RMFRzSDg5T1lNWUEK \ No newline at end of file +stringData: + SECRET_KEY: "your-secret-key-change-in-production" + CLAUDE_API_KEY: "your-claude-api-key" + OPENAI_API_KEY: "your-openai-api-key" \ No newline at end of file From fa1f0a481ab5f75aa98410707ba17b1a83148ce3 Mon Sep 17 00:00:00 2001 From: OhSeongRak Date: Tue, 17 Jun 2025 10:20:47 +0900 Subject: [PATCH 04/31] =?UTF-8?q?refactor:=20secret=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- smarketing-ai/deployment/manifest/secret.yaml | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/smarketing-ai/deployment/manifest/secret.yaml b/smarketing-ai/deployment/manifest/secret.yaml index d013ead..97c1c66 100644 --- a/smarketing-ai/deployment/manifest/secret.yaml +++ b/smarketing-ai/deployment/manifest/secret.yaml @@ -5,6 +5,9 @@ metadata: namespace: smarketing type: Opaque stringData: - SECRET_KEY: "your-secret-key-change-in-production" - CLAUDE_API_KEY: "your-claude-api-key" - OPENAI_API_KEY: "your-openai-api-key" \ No newline at end of file + SECRET_KEY: + CLAUDE_API_KEY: + OPENAI_API_KEY: + AZURE_STORAGE_ACCOUNT_NAME: "stdigitalgarage02" + AZURE_STORAGE_ACCOUNT_KEY: + AZURE_STORAGE_CONTAINER_NAME: "ai-content" From 9b42ff2e00b0dde7dc958f80138171d7c9456d1e Mon Sep 17 00:00:00 2001 From: John Hanzu Kim Date: Tue, 17 Jun 2025 10:30:07 +0900 Subject: [PATCH 05/31] Update deploy.yaml.template --- smarketing-java/deployment/deploy.yaml.template | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/smarketing-java/deployment/deploy.yaml.template b/smarketing-java/deployment/deploy.yaml.template index 4b88867..d14f15e 100644 --- a/smarketing-java/deployment/deploy.yaml.template +++ b/smarketing-java/deployment/deploy.yaml.template @@ -8,6 +8,10 @@ data: ALLOWED_ORIGINS: ${allowed_origins} JPA_DDL_AUTO: update JPA_SHOW_SQL: 'true' + # 🔧 Actuator 보안 설정 추가 + MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE: health,info + MANAGEMENT_ENDPOINT_HEALTH_SHOW_DETAILS: always + MANAGEMENT_SECURITY_ENABLED: 'false' --- apiVersion: v1 From bbeb7b0936c08b6bdc0ce91753ccfb6c50c6b99a Mon Sep 17 00:00:00 2001 From: OhSeongRak Date: Tue, 17 Jun 2025 10:35:07 +0900 Subject: [PATCH 06/31] =?UTF-8?q?refactor:=20secret=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- smarketing-ai/deployment/manifest/secret.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/smarketing-ai/deployment/manifest/secret.yaml b/smarketing-ai/deployment/manifest/secret.yaml index 97c1c66..e489d01 100644 --- a/smarketing-ai/deployment/manifest/secret.yaml +++ b/smarketing-ai/deployment/manifest/secret.yaml @@ -11,3 +11,4 @@ stringData: AZURE_STORAGE_ACCOUNT_NAME: "stdigitalgarage02" AZURE_STORAGE_ACCOUNT_KEY: AZURE_STORAGE_CONTAINER_NAME: "ai-content" + From ae2975668152fdd457c228eced6693fa5e88539d Mon Sep 17 00:00:00 2001 From: OhSeongRak Date: Tue, 17 Jun 2025 10:41:14 +0900 Subject: [PATCH 07/31] =?UTF-8?q?refactor:=20Jenkinsfile=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- smarketing-ai/deployment/Jenkinsfile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/smarketing-ai/deployment/Jenkinsfile b/smarketing-ai/deployment/Jenkinsfile index 1abbdb4..a478c49 100644 --- a/smarketing-ai/deployment/Jenkinsfile +++ b/smarketing-ai/deployment/Jenkinsfile @@ -29,6 +29,10 @@ podTemplate( checkout scm props = readProperties file: "deployment/deploy_env_vars" namespace = "${props.namespace}" + + echo "Registry: ${props.registry}" + echo "Image Org: ${props.image_org}" + echo "Team ID: ${props.teamid}" } stage("Setup AKS") { From 143f93b9a08c28ae78e20caa429fdcc8e5c5980c Mon Sep 17 00:00:00 2001 From: yuhalog Date: Tue, 17 Jun 2025 10:47:15 +0900 Subject: [PATCH 08/31] fix: git merge error fix --- .../service/MarketingTipService.java | 188 +------ .../external/PythonAiTipGenerator.java | 143 ++++++ .../src/main/resources/application.yml | 3 + smarketing-java/build.gradle | 3 + smarketing-java/deployment/Jenkinsfile | 224 ++++++++ .../deployment/container/Dockerfile | 44 ++ .../deployment/deploy.yaml.template | 479 ++++++++++++++++++ smarketing-java/deployment/deploy_env_vars | 23 + smarketing-java/member/Jenkinsfile | 81 +++ .../store/dto/MenuUpdateRequest.java | 3 + 10 files changed, 1014 insertions(+), 177 deletions(-) create mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/PythonAiTipGenerator.java create mode 100644 smarketing-java/deployment/Jenkinsfile create mode 100644 smarketing-java/deployment/container/Dockerfile create mode 100644 smarketing-java/deployment/deploy.yaml.template create mode 100644 smarketing-java/deployment/deploy_env_vars create mode 100644 smarketing-java/member/Jenkinsfile diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java index e6654cf..49b2801 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java @@ -103,14 +103,10 @@ public class MarketingTipService implements MarketingTipUseCase { String aiGeneratedTip = aiTipGenerator.generateTip(storeWithMenuData); log.debug("AI 팁 생성 완료: {}", aiGeneratedTip.substring(0, Math.min(50, aiGeneratedTip.length()))); - String tipSummary = generateTipSummary(aiGeneratedTip); - log.info("tipSummary : {}", tipSummary); - // 도메인 객체 생성 및 저장 MarketingTip marketingTip = MarketingTip.builder() .storeId(storeWithMenuData.getStoreData().getStoreId()) .tipContent(aiGeneratedTip) - .tipSummary(tipSummary) .storeWithMenuData(storeWithMenuData) .createdAt(LocalDateTime.now()) .build(); @@ -126,10 +122,11 @@ public class MarketingTipService implements MarketingTipUseCase { * 마케팅 팁을 응답 DTO로 변환 (전체 내용 포함) */ private MarketingTipResponse convertToResponse(MarketingTip marketingTip, StoreData storeData, boolean isRecentlyCreated) { + String tipSummary = generateTipSummary(marketingTip.getTipContent()); return MarketingTipResponse.builder() .tipId(marketingTip.getId().getValue()) - .tipSummary(marketingTip.getTipSummary()) + .tipSummary(tipSummary) .tipContent(marketingTip.getTipContent()) // 🆕 전체 내용 포함 .storeInfo(MarketingTipResponse.StoreInfo.builder() .storeName(storeData.getStoreName()) @@ -142,187 +139,24 @@ public class MarketingTipService implements MarketingTipUseCase { .build(); } + /** + * 마케팅 팁 요약 생성 (첫 50자 또는 첫 번째 문장) + */ private String generateTipSummary(String fullContent) { if (fullContent == null || fullContent.trim().isEmpty()) { return "마케팅 팁이 생성되었습니다."; } - try { - // JSON 형식 처리: "```html\n..." 패턴 - String processedContent = preprocessContent(fullContent); - - // 1순위: HTML 블록 밖의 첫 번째 제목 추출 - String titleOutsideHtml = extractTitleOutsideHtml(processedContent); - if (titleOutsideHtml != null && titleOutsideHtml.length() > 5) { - return titleOutsideHtml; - } - - // 2순위: 태그 안의 첫 번째 내용 추출 - String boldContent = extractBoldContent(processedContent); - if (boldContent != null && boldContent.length() > 5) { - return boldContent; - } - - // 3순위: HTML 태그 제거 후 첫 번째 문장 - return extractFirstSentence(processedContent); - - } catch (Exception e) { - log.error("마케팅 팁 요약 생성 중 오류", e); - return "마케팅 팁이 생성되었습니다."; - } - } - - /** - * JSON이나 특수 형식 전처리 - */ - private String preprocessContent(String content) { - // 먼저 JSON 이스케이프 문자 정리 - if (content.contains("\\n")) { - content = content.replaceAll("\\\\n", "\n"); - } - - // JSON 구조에서 실제 HTML 내용만 추출 - if (content.contains("```html")) { - content = content.replaceAll("```html", "") - .replaceAll("```", "") - .replaceAll("\"", ""); - } - - return content.trim(); - } - - /** - * HTML 블록 밖의 첫 번째 제목 라인 추출 - * ```html 이후 첫 번째 줄의 내용만 추출 - */ - private String extractTitleOutsideHtml(String content) { - // 먼저 이스케이프 문자 정리 - String processedContent = content.replaceAll("\\\\n", "\n"); - - // ```html 패턴 찾기 (이스케이프 처리 후) - String[] htmlPatterns = {"```html\n", "```html\\n"}; - - for (String pattern : htmlPatterns) { - int htmlStart = processedContent.indexOf(pattern); - if (htmlStart != -1) { - // 패턴 이후부터 시작 - int contentStart = htmlStart + pattern.length(); - - // 첫 번째 줄바꿈까지 또는 \n\n까지 찾기 - String remaining = processedContent.substring(contentStart); - String[] lines = remaining.split("\n"); - - if (lines.length > 0) { - String firstLine = lines[0].trim(); - - // 유효한 내용인지 확인 - if (firstLine.length() > 5 && !firstLine.contains("🎯") && !firstLine.contains("<")) { - return cleanText(firstLine); - } - } - } - } - - // 기존 방식으로 fallback - return extractFromLines(processedContent); - } - - /** - * 줄별로 처리하는 기존 방식 - */ - private String extractFromLines(String content) { - String[] lines = content.split("\n"); - - for (String line : lines) { - line = line.trim(); - - // 빈 줄이나 HTML 태그, 이모지로 시작하는 줄 건너뛰기 - if (line.isEmpty() || - line.contains("<") || - line.startsWith("🎯") || - line.startsWith("🔍") || - line.equals("```html") || - line.matches("^[\\p{So}\\p{Sk}\\s]+$")) { - continue; - } - - // 의미있는 제목 라인 발견 - if (line.length() > 5) { - return cleanText(line); - } - } - - return null; - } - - /** - * 태그 안의 첫 번째 내용 추출 - */ - private String extractBoldContent(String htmlContent) { - int startIndex = htmlContent.indexOf(""); - if (startIndex == -1) { - return null; - } - - int endIndex = htmlContent.indexOf("", startIndex); - if (endIndex == -1) { - return null; - } - - String content = htmlContent.substring(startIndex + 3, endIndex).trim(); - return cleanText(content); - } - - /** - * 텍스트 정리 - */ - private String cleanText(String text) { - if (text == null) { - return null; - } - - return text.replaceAll(" ", " ") - .replaceAll("\\s+", " ") - .trim(); - } - - /** - * HTML 태그 제거 후 첫 번째 의미있는 문장 추출 - */ - private String extractFirstSentence(String htmlContent) { - // HTML 태그 모두 제거 - String cleanContent = htmlContent.replaceAll("<[^>]+>", "").trim(); - - // 줄별로 나누어서 첫 번째 의미있는 줄 찾기 - String[] lines = cleanContent.split("\\n"); - - for (String line : lines) { - line = line.trim(); - - // 빈 줄이나 이모지만 있는 줄 건너뛰기 - if (line.isEmpty() || line.matches("^[\\p{So}\\p{Sk}\\s]+$")) { - continue; - } - - // 최소 길이 체크하고 반환 - if (line.length() > 5) { - // 50자 제한 - if (line.length() > 50) { - return line.substring(0, 50).trim() + "..."; - } - return line; - } - } - - // 모든 방법이 실패하면 기존 방식 사용 - String[] sentences = cleanContent.split("[.!?]"); - String firstSentence = sentences.length > 0 ? sentences[0].trim() : cleanContent; + // 첫 번째 문장으로 요약 (마침표 기준) + String[] sentences = fullContent.split("[.!?]"); + String firstSentence = sentences.length > 0 ? sentences[0].trim() : fullContent; + // 50자 제한 if (firstSentence.length() > 50) { - firstSentence = firstSentence.substring(0, 50).trim() + "..."; + return firstSentence.substring(0, 47) + "..."; } - return firstSentence.isEmpty() ? "마케팅 팁이 생성되었습니다." : firstSentence; + return firstSentence; } /** diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/PythonAiTipGenerator.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/PythonAiTipGenerator.java new file mode 100644 index 0000000..e091fc5 --- /dev/null +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/PythonAiTipGenerator.java @@ -0,0 +1,143 @@ +package com.won.smarketing.recommend.infrastructure.external; + +import com.won.smarketing.recommend.domain.model.MenuData; +import com.won.smarketing.recommend.domain.model.StoreData; +import com.won.smarketing.recommend.domain.model.StoreWithMenuData; +import com.won.smarketing.recommend.domain.service.AiTipGenerator; +import lombok.Getter; +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.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * 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(StoreWithMenuData storeWithMenuData) { + try { + log.debug("Python AI 서비스 직접 호출: store={}", storeWithMenuData.getStoreData().getStoreName()); + return callPythonAiService(storeWithMenuData); + + } catch (Exception e) { + log.error("Python AI 서비스 호출 실패, Fallback 처리: {}", e.getMessage()); + return createFallbackTip(storeWithMenuData); + } + } + + private String callPythonAiService(StoreWithMenuData storeWithMenuData) { + + try { + + StoreData storeData = storeWithMenuData.getStoreData(); + List menuDataList = storeWithMenuData.getMenuDataList(); + + // 메뉴 데이터를 Map 형태로 변환 + List> menuList = menuDataList.stream() + .map(menu -> { + Map menuMap = new HashMap<>(); + menuMap.put("menu_id", menu.getMenuId()); + menuMap.put("menu_name", menu.getMenuName()); + menuMap.put("category", menu.getCategory()); + menuMap.put("price", menu.getPrice()); + menuMap.put("description", menu.getDescription()); + return menuMap; + }) + .collect(Collectors.toList()); + + // Python AI 서비스로 전송할 데이터 (매장 정보 + 메뉴 정보) + Map requestData = new HashMap<>(); + requestData.put("store_name", storeData.getStoreName()); + requestData.put("business_type", storeData.getBusinessType()); + requestData.put("location", storeData.getLocation()); + requestData.put("seat_count", storeData.getSeatCount()); + requestData.put("menu_list", menuList); + + 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(storeWithMenuData); + } + + /** + * 규칙 기반 Fallback 팁 생성 (날씨 정보 없이 매장 정보만 활용) + */ + private String createFallbackTip(StoreWithMenuData storeWithMenuData) { + String businessType = storeWithMenuData.getStoreData().getBusinessType(); + String storeName = storeWithMenuData.getStoreData().getStoreName(); + String location = storeWithMenuData.getStoreData().getLocation(); + + // 업종별 기본 팁 생성 + 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); + } + + @Getter + private static class PythonAiResponse { + private String tip; + private String status; + private String message; + private LocalDateTime generatedTip; + private String businessType; + private String aiModel; + } +} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/resources/application.yml b/smarketing-java/ai-recommend/src/main/resources/application.yml index ee94915..985f4a3 100644 --- a/smarketing-java/ai-recommend/src/main/resources/application.yml +++ b/smarketing-java/ai-recommend/src/main/resources/application.yml @@ -33,12 +33,15 @@ external: api-key: ${PYTHON_AI_API_KEY:dummy-key} timeout: ${PYTHON_AI_TIMEOUT:30000} +<<<<<<< HEAD azure: eventhub: namespace: ${AZURE_EVENTHUB_NAMESPACE} marketing-tip-hub: ${AZURE_EVENTHUB_MARKETING_TIP_HUB:marketing-tip-requests} consumer-group: ${AZURE_EVENTHUB_CONSUMER_GROUP:ai-recommend-service} +======= +>>>>>>> origin/main management: endpoints: web: diff --git a/smarketing-java/build.gradle b/smarketing-java/build.gradle index 30d5b26..fefa680 100644 --- a/smarketing-java/build.gradle +++ b/smarketing-java/build.gradle @@ -47,10 +47,13 @@ subprojects { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' +<<<<<<< HEAD implementation 'com.azure:azure-messaging-eventhubs:5.18.0' implementation 'com.azure:azure-messaging-eventhubs-checkpointstore-blob:1.19.0' implementation 'com.azure:azure-identity:1.11.4' +======= +>>>>>>> origin/main } tasks.named('test') { diff --git a/smarketing-java/deployment/Jenkinsfile b/smarketing-java/deployment/Jenkinsfile new file mode 100644 index 0000000..f9a348d --- /dev/null +++ b/smarketing-java/deployment/Jenkinsfile @@ -0,0 +1,224 @@ +def PIPELINE_ID = "${env.BUILD_NUMBER}" + +def getImageTag() { + def dateFormat = new java.text.SimpleDateFormat('yyyyMMddHHmmss') + def currentDate = new Date() + return dateFormat.format(currentDate) +} + +podTemplate( + label: "${PIPELINE_ID}", + serviceAccount: 'jenkins', + containers: [ + containerTemplate(name: 'gradle', image: 'gradle:jdk17', ttyEnabled: true, command: 'cat'), + containerTemplate(name: 'docker', image: 'docker:20.10.16-dind', ttyEnabled: true, privileged: true), + containerTemplate(name: 'azure-cli', image: 'hiondal/azure-kubectl:latest', command: 'cat', ttyEnabled: true), + containerTemplate(name: 'envsubst', image: "hiondal/envsubst", command: 'sleep', args: '1h') + ], + volumes: [ + emptyDirVolume(mountPath: '/home/gradle/.gradle', memory: false), + emptyDirVolume(mountPath: '/root/.azure', memory: false), + emptyDirVolume(mountPath: '/var/run', memory: false) + ] +) { + node(PIPELINE_ID) { + def props + def imageTag = getImageTag() + def manifest = "deploy.yaml" + def namespace + def services = ['member', 'store', 'marketing-content', 'ai-recommend'] + + stage("Get Source") { + checkout scm + + // smarketing-java 하위에 있는 설정 파일 읽기 + props = readProperties file: "smarketing-java/deployment/deploy_env_vars" + namespace = "${props.namespace}" + + echo "=== Build Information ===" + echo "Services: ${services}" + echo "Namespace: ${namespace}" + echo "Image Tag: ${imageTag}" + } + + stage("Setup AKS") { + container('azure-cli') { + withCredentials([azureServicePrincipal('azure-credentials')]) { + sh """ + echo "=== Azure 로그인 ===" + az login --service-principal -u \$AZURE_CLIENT_ID -p \$AZURE_CLIENT_SECRET -t \$AZURE_TENANT_ID + az account set --subscription 2513dd36-7978-48e3-9a7c-b221d4874f66 + + echo "=== AKS 인증정보 가져오기 (rg-digitalgarage-02) ===" + az aks get-credentials --resource-group rg-digitalgarage-02 --name aks-digitalgarage-02 --overwrite-existing + + echo "=== 네임스페이스 생성 ===" + kubectl create namespace ${namespace} --dry-run=client -o yaml | kubectl apply -f - + + echo "=== Image Pull Secret 생성 ===" + kubectl create secret docker-registry acr-secret \\ + --docker-server=${props.registry} \\ + --docker-username=acrdigitalgarage02 \\ + --docker-password=\$(az acr credential show --name acrdigitalgarage02 --query passwords[0].value -o tsv) \\ + --namespace=${namespace} \\ + --dry-run=client -o yaml | kubectl apply -f - + + echo "=== 클러스터 상태 확인 ===" + kubectl get nodes + kubectl get ns ${namespace} + + echo "=== 현재 연결된 클러스터 확인 ===" + kubectl config current-context + """ + } + } + } + + stage('Build Applications') { + container('gradle') { + sh """ + echo "=== smarketing-java 디렉토리로 이동 ===" + cd smarketing-java + + echo "=== gradlew 권한 설정 ===" + chmod +x gradlew + + echo "=== 전체 서비스 빌드 ===" + ./gradlew :member:clean :member:build -x test + ./gradlew :store:clean :store:build -x test + ./gradlew :marketing-content:clean :marketing-content:build -x test + ./gradlew :ai-recommend:clean :ai-recommend:build -x test + + echo "=== 빌드 결과 확인 ===" + find . -name "*.jar" -path "*/build/libs/*" | grep -v 'plain.jar' + """ + } + } + + stage('Build & Push Images') { + container('docker') { + sh """ + echo "=== Docker 데몬 시작 대기 ===" + timeout 30 sh -c 'until docker info; do sleep 1; done' + """ + + // ACR Credential을 Jenkins에서 직접 사용 + withCredentials([usernamePassword( + credentialsId: 'acr-credentials', + usernameVariable: 'ACR_USERNAME', + passwordVariable: 'ACR_PASSWORD' + )]) { + sh """ + echo "=== Docker로 ACR 로그인 ===" + echo "\$ACR_PASSWORD" | docker login ${props.registry} --username \$ACR_USERNAME --password-stdin + """ + + services.each { service -> + script { + def buildDir = "smarketing-java/${service}" + def fullImageName = "${props.registry}/${props.image_org}/${service}:${imageTag}" + + echo "Building image for ${service}: ${fullImageName}" + + // 실제 JAR 파일명 동적 탐지 + def actualJarFile = sh( + script: """ + cd ${buildDir}/build/libs + ls *.jar | grep -v 'plain.jar' | head -1 + """, + returnStdout: true + ).trim() + + if (!actualJarFile) { + error "${service} JAR 파일을 찾을 수 없습니다" + } + + echo "발견된 JAR 파일: ${actualJarFile}" + + sh """ + echo "=== ${service} 이미지 빌드 ===" + docker build \\ + --build-arg BUILD_LIB_DIR="${buildDir}/build/libs" \\ + --build-arg ARTIFACTORY_FILE="${actualJarFile}" \\ + -f smarketing-java/deployment/container/Dockerfile \\ + -t ${fullImageName} . + + echo "=== ${service} 이미지 푸시 ===" + docker push ${fullImageName} + + echo "Successfully built and pushed: ${fullImageName}" + """ + } + } + } + } + } + + stage('Generate & Apply Manifest') { + container('envsubst') { + sh """ + echo "=== 환경변수 설정 ===" + export namespace=${namespace} + export allowed_origins=${props.allowed_origins} + export jwt_secret_key=${props.jwt_secret_key} + export postgres_user=${props.postgres_user} + export postgres_password=${props.postgres_password} + export replicas=${props.replicas} + # 리소스 요구사항 조정 (작게) + export resources_requests_cpu=100m + export resources_requests_memory=128Mi + export resources_limits_cpu=500m + export resources_limits_memory=512Mi + + # 이미지 경로 환경변수 설정 + export member_image_path=${props.registry}/${props.image_org}/member:${imageTag} + export store_image_path=${props.registry}/${props.image_org}/store:${imageTag} + export marketing_content_image_path=${props.registry}/${props.image_org}/marketing-content:${imageTag} + export ai_recommend_image_path=${props.registry}/${props.image_org}/ai-recommend:${imageTag} + + echo "=== Manifest 생성 ===" + envsubst < smarketing-java/deployment/${manifest}.template > smarketing-java/deployment/${manifest} + + echo "=== Generated Manifest File ===" + cat smarketing-java/deployment/${manifest} + echo "===============================" + """ + } + + container('azure-cli') { + sh """ + echo "=== 현재 연결된 클러스터 재확인 ===" + kubectl config current-context + kubectl cluster-info | head -3 + + echo "=== PostgreSQL 서비스 확인 ===" + kubectl get svc -n ${namespace} | grep postgresql || echo "PostgreSQL 서비스가 없습니다. 먼저 설치해주세요." + + echo "=== Manifest 적용 ===" + kubectl apply -f smarketing-java/deployment/${manifest} + + echo "=== 배포 상태 확인 (60초 대기) ===" + kubectl -n ${namespace} get deployments + kubectl -n ${namespace} get pods + + echo "=== 각 서비스 배포 대기 (60초 timeout) ===" + timeout 60 kubectl -n ${namespace} wait --for=condition=available deployment/member --timeout=60s || echo "member deployment 대기 타임아웃" + timeout 60 kubectl -n ${namespace} wait --for=condition=available deployment/store --timeout=60s || echo "store deployment 대기 타임아웃" + timeout 60 kubectl -n ${namespace} wait --for=condition=available deployment/marketing-content --timeout=60s || echo "marketing-content deployment 대기 타임아웃" + timeout 60 kubectl -n ${namespace} wait --for=condition=available deployment/ai-recommend --timeout=60s || echo "ai-recommend deployment 대기 타임아웃" + + echo "=== 최종 상태 ===" + kubectl -n ${namespace} get all + + echo "=== 실패한 Pod 상세 정보 ===" + for pod in \$(kubectl -n ${namespace} get pods --field-selector=status.phase!=Running -o name 2>/dev/null || true); do + if [ ! -z "\$pod" ]; then + echo "=== 실패한 Pod: \$pod ===" + kubectl -n ${namespace} describe \$pod | tail -20 + fi + done + """ + } + } + } +} diff --git a/smarketing-java/deployment/container/Dockerfile b/smarketing-java/deployment/container/Dockerfile new file mode 100644 index 0000000..be0f578 --- /dev/null +++ b/smarketing-java/deployment/container/Dockerfile @@ -0,0 +1,44 @@ +# Build stage +FROM eclipse-temurin:17-jre AS builder +ARG BUILD_LIB_DIR +ARG ARTIFACTORY_FILE +WORKDIR /app +COPY ${BUILD_LIB_DIR}/${ARTIFACTORY_FILE} app.jar + +# Run stage +FROM eclipse-temurin:17-jre + +# Install necessary packages +RUN apt-get update && apt-get install -y \ + curl \ + netcat-traditional \ + && rm -rf /var/lib/apt/lists/* + +ENV USERNAME k8s +ENV ARTIFACTORY_HOME /home/${USERNAME} +ENV JAVA_OPTS="" + +# Add a non-root user +RUN groupadd -r ${USERNAME} && useradd -r -g ${USERNAME} ${USERNAME} && \ + mkdir -p ${ARTIFACTORY_HOME} && \ + chown ${USERNAME}:${USERNAME} ${ARTIFACTORY_HOME} + +WORKDIR ${ARTIFACTORY_HOME} + +# Copy JAR from builder stage +COPY --from=builder /app/app.jar app.jar +RUN chown ${USERNAME}:${USERNAME} app.jar + +# Switch to non-root user +USER ${USERNAME} + +# Expose port +EXPOSE 8080 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=60s --retries=3 \ + CMD curl -f http://localhost:8080/actuator/health || exit 1 + +# Run the application +ENTRYPOINT ["sh", "-c"] +CMD ["java ${JAVA_OPTS} -jar app.jar"] diff --git a/smarketing-java/deployment/deploy.yaml.template b/smarketing-java/deployment/deploy.yaml.template new file mode 100644 index 0000000..d14f15e --- /dev/null +++ b/smarketing-java/deployment/deploy.yaml.template @@ -0,0 +1,479 @@ +# ConfigMap +apiVersion: v1 +kind: ConfigMap +metadata: + name: common-config + namespace: ${namespace} +data: + ALLOWED_ORIGINS: ${allowed_origins} + JPA_DDL_AUTO: update + JPA_SHOW_SQL: 'true' + # 🔧 Actuator 보안 설정 추가 + MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE: health,info + MANAGEMENT_ENDPOINT_HEALTH_SHOW_DETAILS: always + MANAGEMENT_SECURITY_ENABLED: 'false' + +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: member-config + namespace: ${namespace} +data: + POSTGRES_DB: member + POSTGRES_HOST: member-postgresql + POSTGRES_PORT: '5432' + SERVER_PORT: '8081' + +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: store-config + namespace: ${namespace} +data: + POSTGRES_DB: store + POSTGRES_HOST: store-postgresql + POSTGRES_PORT: '5432' + SERVER_PORT: '8082' + +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: marketing-content-config + namespace: ${namespace} +data: + POSTGRES_DB: marketing_content + POSTGRES_HOST: marketing-content-postgresql + POSTGRES_PORT: '5432' + SERVER_PORT: '8083' + +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: ai-recommend-config + namespace: ${namespace} +data: + POSTGRES_DB: ai_recommend + POSTGRES_HOST: ai-recommend-postgresql + POSTGRES_PORT: '5432' + SERVER_PORT: '8084' + +--- +# Secrets +apiVersion: v1 +kind: Secret +metadata: + name: common-secret + namespace: ${namespace} +stringData: + JWT_SECRET_KEY: ${jwt_secret_key} +type: Opaque + +--- +apiVersion: v1 +kind: Secret +metadata: + name: member-secret + namespace: ${namespace} +stringData: + JWT_ACCESS_TOKEN_VALIDITY: '3600000' + JWT_REFRESH_TOKEN_VALIDITY: '86400000' + POSTGRES_PASSWORD: ${postgres_password} + POSTGRES_USER: ${postgres_user} +type: Opaque + +--- +apiVersion: v1 +kind: Secret +metadata: + name: store-secret + namespace: ${namespace} +stringData: + POSTGRES_PASSWORD: ${postgres_password} + POSTGRES_USER: ${postgres_user} +type: Opaque + +--- +apiVersion: v1 +kind: Secret +metadata: + name: marketing-content-secret + namespace: ${namespace} +stringData: + POSTGRES_PASSWORD: ${postgres_password} + POSTGRES_USER: ${postgres_user} +type: Opaque + +--- +apiVersion: v1 +kind: Secret +metadata: + name: ai-recommend-secret + namespace: ${namespace} +stringData: + POSTGRES_PASSWORD: ${postgres_password} + POSTGRES_USER: ${postgres_user} +type: Opaque + +--- +# Deployments +apiVersion: apps/v1 +kind: Deployment +metadata: + name: member + namespace: ${namespace} + labels: + app: member +spec: + replicas: ${replicas} + selector: + matchLabels: + app: member + template: + metadata: + labels: + app: member + spec: + imagePullSecrets: + - name: acr-secret + containers: + - name: member + image: ${member_image_path} + imagePullPolicy: Always + ports: + - containerPort: 8081 + resources: + requests: + cpu: ${resources_requests_cpu} + memory: ${resources_requests_memory} + limits: + cpu: ${resources_limits_cpu} + memory: ${resources_limits_memory} + envFrom: + - configMapRef: + name: common-config + - configMapRef: + name: member-config + - secretRef: + name: common-secret + - secretRef: + name: member-secret + startupProbe: + exec: + command: + - /bin/sh + - -c + - "nc -z member-postgresql 5432" + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 10 + livenessProbe: + httpGet: + path: /actuator/health + port: 8081 + initialDelaySeconds: 60 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /actuator/health + port: 8081 + initialDelaySeconds: 30 + periodSeconds: 5 + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: store + namespace: ${namespace} + labels: + app: store +spec: + replicas: ${replicas} + selector: + matchLabels: + app: store + template: + metadata: + labels: + app: store + spec: + imagePullSecrets: + - name: acr-secret + containers: + - name: store + image: ${store_image_path} + imagePullPolicy: Always + ports: + - containerPort: 8082 + resources: + requests: + cpu: ${resources_requests_cpu} + memory: ${resources_requests_memory} + limits: + cpu: ${resources_limits_cpu} + memory: ${resources_limits_memory} + envFrom: + - configMapRef: + name: common-config + - configMapRef: + name: store-config + - secretRef: + name: common-secret + - secretRef: + name: store-secret + startupProbe: + exec: + command: + - /bin/sh + - -c + - "nc -z store-postgresql 5432" + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 10 + livenessProbe: + httpGet: + path: /actuator/health + port: 8082 + initialDelaySeconds: 60 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /actuator/health + port: 8082 + initialDelaySeconds: 30 + periodSeconds: 5 + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: marketing-content + namespace: ${namespace} + labels: + app: marketing-content +spec: + replicas: ${replicas} + selector: + matchLabels: + app: marketing-content + template: + metadata: + labels: + app: marketing-content + spec: + imagePullSecrets: + - name: acr-secret + containers: + - name: marketing-content + image: ${marketing_content_image_path} + imagePullPolicy: Always + ports: + - containerPort: 8083 + resources: + requests: + cpu: ${resources_requests_cpu} + memory: ${resources_requests_memory} + limits: + cpu: ${resources_limits_cpu} + memory: ${resources_limits_memory} + envFrom: + - configMapRef: + name: common-config + - configMapRef: + name: marketing-content-config + - secretRef: + name: common-secret + - secretRef: + name: marketing-content-secret + startupProbe: + exec: + command: + - /bin/sh + - -c + - "nc -z marketing-content-postgresql 5432" + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 10 + livenessProbe: + httpGet: + path: /actuator/health + port: 8083 + initialDelaySeconds: 60 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /actuator/health + port: 8083 + initialDelaySeconds: 30 + periodSeconds: 5 + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ai-recommend + namespace: ${namespace} + labels: + app: ai-recommend +spec: + replicas: ${replicas} + selector: + matchLabels: + app: ai-recommend + template: + metadata: + labels: + app: ai-recommend + spec: + imagePullSecrets: + - name: acr-secret + containers: + - name: ai-recommend + image: ${ai_recommend_image_path} + imagePullPolicy: Always + ports: + - containerPort: 8084 + resources: + requests: + cpu: ${resources_requests_cpu} + memory: ${resources_requests_memory} + limits: + cpu: ${resources_limits_cpu} + memory: ${resources_limits_memory} + envFrom: + - configMapRef: + name: common-config + - configMapRef: + name: ai-recommend-config + - secretRef: + name: common-secret + - secretRef: + name: ai-recommend-secret + startupProbe: + exec: + command: + - /bin/sh + - -c + - "nc -z ai-recommend-postgresql 5432" + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 10 + livenessProbe: + httpGet: + path: /actuator/health + port: 8084 + initialDelaySeconds: 60 + periodSeconds: 30 + readinessProbe: + httpGet: + path: /actuator/health + port: 8084 + initialDelaySeconds: 30 + periodSeconds: 5 + +--- +# Services +apiVersion: v1 +kind: Service +metadata: + name: member + namespace: ${namespace} +spec: + selector: + app: member + ports: + - port: 80 + targetPort: 8081 + type: ClusterIP + +--- +apiVersion: v1 +kind: Service +metadata: + name: store + namespace: ${namespace} +spec: + selector: + app: store + ports: + - port: 80 + targetPort: 8082 + type: ClusterIP + +--- +apiVersion: v1 +kind: Service +metadata: + name: marketing-content + namespace: ${namespace} +spec: + selector: + app: marketing-content + ports: + - port: 80 + targetPort: 8083 + type: ClusterIP + +--- +apiVersion: v1 +kind: Service +metadata: + name: ai-recommend + namespace: ${namespace} +spec: + selector: + app: ai-recommend + ports: + - port: 80 + targetPort: 8084 + type: ClusterIP + +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: smarketing-backend + namespace: ${namespace} + annotations: + kubernetes.io/ingress.class: nginx +spec: + ingressClassName: nginx + rules: + - http: + paths: + - path: /api/auth + pathType: Prefix + backend: + service: + name: member + port: + number: 80 + - path: /api/store + pathType: Prefix + backend: + service: + name: store + port: + number: 80 + - path: /api/content + pathType: Prefix + backend: + service: + name: marketing-content + port: + number: 80 + - path: /api/recommend + pathType: Prefix + backend: + service: + name: ai-recommend + port: + number: 80 diff --git a/smarketing-java/deployment/deploy_env_vars b/smarketing-java/deployment/deploy_env_vars new file mode 100644 index 0000000..db95eda --- /dev/null +++ b/smarketing-java/deployment/deploy_env_vars @@ -0,0 +1,23 @@ +# Team Settings +teamid=kros235 +root_project=smarketing-backend +namespace=smarketing + +# Container Registry Settings +registry=acrdigitalgarage02.azurecr.io +image_org=smarketing + +# Application Settings +replicas=1 +allowed_origins=http://20.249.171.38 + +# Security Settings +jwt_secret_key=8O2HQ13etL2BWZvYOiWsJ5uWFoLi6NBUG8divYVoCgtHVvlk3dqRksMl16toztDUeBTSIuOOPvHIrYq11G2BwQ +postgres_user=admin +postgres_password=Hi5Jessica! + +# Resource Settings (리소스 요구사항 줄임) +resources_requests_cpu=100m +resources_requests_memory=128Mi +resources_limits_cpu=500m +resources_limits_memory=512Mi diff --git a/smarketing-java/member/Jenkinsfile b/smarketing-java/member/Jenkinsfile new file mode 100644 index 0000000..3267d52 --- /dev/null +++ b/smarketing-java/member/Jenkinsfile @@ -0,0 +1,81 @@ +pipeline { + agent any + + environment { + ACR_LOGIN_SERVER = 'acrsmarketing17567.azurecr.io' + IMAGE_NAME = 'member' + MANIFEST_REPO = 'https://github.com/won-ktds/smarketing-manifest.git' + MANIFEST_PATH = 'member/deployment.yaml' + } + + stages { + stage('Checkout') { + steps { + checkout scm + } + } + + stage('Build') { + steps { + dir('member') { + sh './gradlew clean build -x test' + } + } + } + + stage('Test') { + steps { + dir('member') { + sh './gradlew test' + } + } + } + + stage('Build Docker Image') { + steps { + script { + def imageTag = "${BUILD_NUMBER}-${env.GIT_COMMIT.substring(0,8)}" + def fullImageName = "${ACR_LOGIN_SERVER}/${IMAGE_NAME}:${imageTag}" + + dir('member') { + sh "docker build -t ${fullImageName} ." + } + + withCredentials([usernamePassword(credentialsId: 'acr-credentials', usernameVariable: 'ACR_USERNAME', passwordVariable: 'ACR_PASSWORD')]) { + sh "docker login ${ACR_LOGIN_SERVER} -u ${ACR_USERNAME} -p ${ACR_PASSWORD}" + sh "docker push ${fullImageName}" + } + + env.IMAGE_TAG = imageTag + env.FULL_IMAGE_NAME = fullImageName + } + } + } + + stage('Update Manifest') { + steps { + withCredentials([usernamePassword(credentialsId: 'github-credentials', usernameVariable: 'GIT_USERNAME', passwordVariable: 'GIT_TOKEN')]) { + sh ''' + git clone https://${GIT_TOKEN}@github.com/won-ktds/smarketing-manifest.git manifest-repo + cd manifest-repo + + # Update image tag in deployment.yaml + sed -i "s|image: .*|image: ${FULL_IMAGE_NAME}|g" ${MANIFEST_PATH} + + git config user.email "jenkins@smarketing.com" + git config user.name "Jenkins" + git add . + git commit -m "Update ${IMAGE_NAME} image to ${IMAGE_TAG}" + git push origin main + ''' + } + } + } + } + + post { + always { + cleanWs() + } + } +} diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java index da0360a..c5ed581 100644 --- a/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java @@ -35,8 +35,11 @@ public class MenuUpdateRequest { @Schema(description = "메뉴 설명", example = "진한 원두의 깊은 맛") private String description; +<<<<<<< HEAD @Schema(description = "이미지") @Size(max = 500, message = "이미지 URL은 500자 이하여야 합니다") private MultipartFile image; +======= +>>>>>>> origin/main } From 42f59b5a08d77b554f9191f0dc2e60485560f732 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=84=9C=EC=9D=80?= <61147083+BangSun98@users.noreply.github.com> Date: Tue, 17 Jun 2025 10:53:17 +0900 Subject: [PATCH 09/31] add image column --- .../java/com/won/smarketing/store/service/MenuServiceImpl.java | 1 + 1 file changed, 1 insertion(+) diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/service/MenuServiceImpl.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/MenuServiceImpl.java index d75efc2..82b1037 100644 --- a/smarketing-java/store/src/main/java/com/won/smarketing/store/service/MenuServiceImpl.java +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/MenuServiceImpl.java @@ -117,6 +117,7 @@ public class MenuServiceImpl implements MenuService { .menuId(menu.getMenuId()) .menuName(menu.getMenuName()) .category(menu.getCategory()) + .image(menu.getImage()) .price(menu.getPrice()) .description(menu.getDescription()) .createdAt(menu.getCreatedAt()) From d6c809d4702af7bbd3c1cf2b33b19c8a1d4055b5 Mon Sep 17 00:00:00 2001 From: yuhalog Date: Tue, 17 Jun 2025 10:55:01 +0900 Subject: [PATCH 10/31] fix: merge fix --- .../ai-recommend/src/main/resources/application.yml | 3 --- smarketing-java/build.gradle | 4 +--- .../java/com/won/smarketing/store/dto/MenuUpdateRequest.java | 4 +--- 3 files changed, 2 insertions(+), 9 deletions(-) diff --git a/smarketing-java/ai-recommend/src/main/resources/application.yml b/smarketing-java/ai-recommend/src/main/resources/application.yml index 985f4a3..ee94915 100644 --- a/smarketing-java/ai-recommend/src/main/resources/application.yml +++ b/smarketing-java/ai-recommend/src/main/resources/application.yml @@ -33,15 +33,12 @@ external: api-key: ${PYTHON_AI_API_KEY:dummy-key} timeout: ${PYTHON_AI_TIMEOUT:30000} -<<<<<<< HEAD azure: eventhub: namespace: ${AZURE_EVENTHUB_NAMESPACE} marketing-tip-hub: ${AZURE_EVENTHUB_MARKETING_TIP_HUB:marketing-tip-requests} consumer-group: ${AZURE_EVENTHUB_CONSUMER_GROUP:ai-recommend-service} -======= ->>>>>>> origin/main management: endpoints: web: diff --git a/smarketing-java/build.gradle b/smarketing-java/build.gradle index fefa680..d0a82d4 100644 --- a/smarketing-java/build.gradle +++ b/smarketing-java/build.gradle @@ -47,13 +47,11 @@ subprojects { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' -<<<<<<< HEAD implementation 'com.azure:azure-messaging-eventhubs:5.18.0' implementation 'com.azure:azure-messaging-eventhubs-checkpointstore-blob:1.19.0' implementation 'com.azure:azure-identity:1.11.4' -======= ->>>>>>> origin/main + } tasks.named('test') { diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java index c5ed581..e94097e 100644 --- a/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java @@ -35,11 +35,9 @@ public class MenuUpdateRequest { @Schema(description = "메뉴 설명", example = "진한 원두의 깊은 맛") private String description; -<<<<<<< HEAD @Schema(description = "이미지") @Size(max = 500, message = "이미지 URL은 500자 이하여야 합니다") private MultipartFile image; -======= ->>>>>>> origin/main + } From 9d30b7491c7c465907977daebcb8e2399a7b48fa Mon Sep 17 00:00:00 2001 From: OhSeongRak Date: Tue, 17 Jun 2025 11:04:20 +0900 Subject: [PATCH 11/31] =?UTF-8?q?refactor:=20Jenkinsfile=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20-=20Check=20Change=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- smarketing-ai/deployment/Jenkinsfile | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/smarketing-ai/deployment/Jenkinsfile b/smarketing-ai/deployment/Jenkinsfile index a478c49..ef25948 100644 --- a/smarketing-ai/deployment/Jenkinsfile +++ b/smarketing-ai/deployment/Jenkinsfile @@ -35,6 +35,25 @@ podTemplate( echo "Team ID: ${props.teamid}" } + stage("Check Changes") { + script { + def changes = sh( + script: "git diff --name-only HEAD~1 HEAD", + returnStdout: true + ).trim() + + echo "Changed files: ${changes}" + + if (!changes.contains("smarketing-ai/")) { + echo "No changes in smarketing-ai, skipping build" + currentBuild.result = 'SUCCESS' + return + } + + echo "Changes detected in smarketing-ai, proceeding with build" + } + } + stage("Setup AKS") { container('azure-cli') { withCredentials([azureServicePrincipal('azure-credentials')]) { From d94915f15dbeb532f3edca1a51193824839c6516 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=84=9C=EC=9D=80?= <61147083+BangSun98@users.noreply.github.com> Date: Tue, 17 Jun 2025 11:13:27 +0900 Subject: [PATCH 12/31] delete unused import --- .../java/com/won/smarketing/store/service/MenuServiceImpl.java | 1 - 1 file changed, 1 deletion(-) diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/service/MenuServiceImpl.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/MenuServiceImpl.java index 82b1037..d1a6c4f 100644 --- a/smarketing-java/store/src/main/java/com/won/smarketing/store/service/MenuServiceImpl.java +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/service/MenuServiceImpl.java @@ -11,7 +11,6 @@ import com.won.smarketing.store.repository.MenuRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.multipart.MultipartFile; import java.util.List; import java.util.stream.Collectors; From 2e1a7b589e7f2eaa5f59925cf6292b37c6fe3b3c Mon Sep 17 00:00:00 2001 From: OhSeongRak Date: Tue, 17 Jun 2025 11:15:12 +0900 Subject: [PATCH 13/31] =?UTF-8?q?refactor:=20Jenkinsfile=20-=20Check=20Cha?= =?UTF-8?q?nges=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- smarketing-java/deployment/Jenkinsfile | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/smarketing-java/deployment/Jenkinsfile b/smarketing-java/deployment/Jenkinsfile index f9a348d..79b7055 100644 --- a/smarketing-java/deployment/Jenkinsfile +++ b/smarketing-java/deployment/Jenkinsfile @@ -41,6 +41,25 @@ podTemplate( echo "Image Tag: ${imageTag}" } + stage("Check Changes") { + script { + def changes = sh( + script: "git diff --name-only HEAD~1 HEAD", + returnStdout: true + ).trim() + + echo "Changed files: ${changes}" + + if (!changes.contains("smarketing-ai/")) { + echo "No changes in smarketing-ai, skipping build" + currentBuild.result = 'SUCCESS' + return + } + + echo "Changes detected in smarketing-ai, proceeding with build" + } + } + stage("Setup AKS") { container('azure-cli') { withCredentials([azureServicePrincipal('azure-credentials')]) { From b7fa318ba9da6c24d4d1b267134fba149d468459 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=84=9C=EC=9D=80?= <61147083+BangSun98@users.noreply.github.com> Date: Tue, 17 Jun 2025 11:17:12 +0900 Subject: [PATCH 14/31] delete unused import --- .../java/com/won/smarketing/store/dto/MenuUpdateRequest.java | 1 - 1 file changed, 1 deletion(-) diff --git a/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java index 4df4894..3288be6 100644 --- a/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java +++ b/smarketing-java/store/src/main/java/com/won/smarketing/store/dto/MenuUpdateRequest.java @@ -8,7 +8,6 @@ import lombok.NoArgsConstructor; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.Size; -import org.springframework.web.multipart.MultipartFile; /** * 메뉴 수정 요청 DTO From 96e3372d080f99a400f567f78825d64a98b29172 Mon Sep 17 00:00:00 2001 From: OhSeongRak Date: Tue, 17 Jun 2025 11:19:32 +0900 Subject: [PATCH 15/31] =?UTF-8?q?refactor:=20Jenkinsfile=20-=20deploy=5Fen?= =?UTF-8?q?v=5Fvars=20=EA=B2=BD=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- smarketing-ai/deployment/Jenkinsfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/smarketing-ai/deployment/Jenkinsfile b/smarketing-ai/deployment/Jenkinsfile index ef25948..5734088 100644 --- a/smarketing-ai/deployment/Jenkinsfile +++ b/smarketing-ai/deployment/Jenkinsfile @@ -27,7 +27,7 @@ podTemplate( stage("Get Source") { checkout scm - props = readProperties file: "deployment/deploy_env_vars" + props = readProperties file: "smarketing-ai/deployment/deploy_env_vars" namespace = "${props.namespace}" echo "Registry: ${props.registry}" From f7f1e08b49a575f64223dbb4c3307edb89f73fee Mon Sep 17 00:00:00 2001 From: yuhalog Date: Tue, 17 Jun 2025 11:20:35 +0900 Subject: [PATCH 16/31] =?UTF-8?q?add:=20actuator=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../external/PythonAiTipGenerator.java | 143 ------------------ .../src/main/resources/application.yml | 14 +- smarketing-java/build.gradle | 1 + .../src/main/resources/application.yml | 18 +++ .../member/src/main/resources/application.yml | 18 +++ .../store/src/main/resources/application.yml | 18 +++ 6 files changed, 67 insertions(+), 145 deletions(-) delete mode 100644 smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/PythonAiTipGenerator.java diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/PythonAiTipGenerator.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/PythonAiTipGenerator.java deleted file mode 100644 index e091fc5..0000000 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/infrastructure/external/PythonAiTipGenerator.java +++ /dev/null @@ -1,143 +0,0 @@ -package com.won.smarketing.recommend.infrastructure.external; - -import com.won.smarketing.recommend.domain.model.MenuData; -import com.won.smarketing.recommend.domain.model.StoreData; -import com.won.smarketing.recommend.domain.model.StoreWithMenuData; -import com.won.smarketing.recommend.domain.service.AiTipGenerator; -import lombok.Getter; -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.time.LocalDateTime; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; - -/** - * 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(StoreWithMenuData storeWithMenuData) { - try { - log.debug("Python AI 서비스 직접 호출: store={}", storeWithMenuData.getStoreData().getStoreName()); - return callPythonAiService(storeWithMenuData); - - } catch (Exception e) { - log.error("Python AI 서비스 호출 실패, Fallback 처리: {}", e.getMessage()); - return createFallbackTip(storeWithMenuData); - } - } - - private String callPythonAiService(StoreWithMenuData storeWithMenuData) { - - try { - - StoreData storeData = storeWithMenuData.getStoreData(); - List menuDataList = storeWithMenuData.getMenuDataList(); - - // 메뉴 데이터를 Map 형태로 변환 - List> menuList = menuDataList.stream() - .map(menu -> { - Map menuMap = new HashMap<>(); - menuMap.put("menu_id", menu.getMenuId()); - menuMap.put("menu_name", menu.getMenuName()); - menuMap.put("category", menu.getCategory()); - menuMap.put("price", menu.getPrice()); - menuMap.put("description", menu.getDescription()); - return menuMap; - }) - .collect(Collectors.toList()); - - // Python AI 서비스로 전송할 데이터 (매장 정보 + 메뉴 정보) - Map requestData = new HashMap<>(); - requestData.put("store_name", storeData.getStoreName()); - requestData.put("business_type", storeData.getBusinessType()); - requestData.put("location", storeData.getLocation()); - requestData.put("seat_count", storeData.getSeatCount()); - requestData.put("menu_list", menuList); - - 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(storeWithMenuData); - } - - /** - * 규칙 기반 Fallback 팁 생성 (날씨 정보 없이 매장 정보만 활용) - */ - private String createFallbackTip(StoreWithMenuData storeWithMenuData) { - String businessType = storeWithMenuData.getStoreData().getBusinessType(); - String storeName = storeWithMenuData.getStoreData().getStoreName(); - String location = storeWithMenuData.getStoreData().getLocation(); - - // 업종별 기본 팁 생성 - 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); - } - - @Getter - private static class PythonAiResponse { - private String tip; - private String status; - private String message; - private LocalDateTime generatedTip; - private String businessType; - private String aiModel; - } -} \ No newline at end of file diff --git a/smarketing-java/ai-recommend/src/main/resources/application.yml b/smarketing-java/ai-recommend/src/main/resources/application.yml index ee94915..72aa217 100644 --- a/smarketing-java/ai-recommend/src/main/resources/application.yml +++ b/smarketing-java/ai-recommend/src/main/resources/application.yml @@ -43,10 +43,13 @@ management: endpoints: web: exposure: - include: health,info,metrics + include: health,info + base-path: /actuator endpoint: health: show-details: always + info: + enabled: true logging: level: @@ -55,4 +58,11 @@ logging: jwt: secret: ${JWT_SECRET:mySecretKeyForJWTTokenGenerationAndValidation123456789} access-token-validity: ${JWT_ACCESS_VALIDITY:3600000} - refresh-token-validity: ${JWT_REFRESH_VALIDITY:604800000} \ No newline at end of file + refresh-token-validity: ${JWT_REFRESH_VALIDITY:604800000} + + +info: + app: + name: ${APP_NAME:smarketing-recommend} + version: "1.0.0-MVP" + description: "AI 마케팅 서비스 MVP - recommend" \ No newline at end of file diff --git a/smarketing-java/build.gradle b/smarketing-java/build.gradle index d0a82d4..e917ca4 100644 --- a/smarketing-java/build.gradle +++ b/smarketing-java/build.gradle @@ -35,6 +35,7 @@ subprojects { 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.springframework.boot:spring-boot-starter-actuator' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' implementation 'io.jsonwebtoken:jjwt-api:0.12.3' implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' diff --git a/smarketing-java/marketing-content/src/main/resources/application.yml b/smarketing-java/marketing-content/src/main/resources/application.yml index 59b0b54..aefce5c 100644 --- a/smarketing-java/marketing-content/src/main/resources/application.yml +++ b/smarketing-java/marketing-content/src/main/resources/application.yml @@ -37,3 +37,21 @@ logging: external: ai-service: base-url: ${AI_SERVICE_BASE_URL:http://20.249.139.88:5001} + +management: + endpoints: + web: + exposure: + include: health,info + base-path: /actuator + endpoint: + health: + show-details: always + info: + enabled: true + +info: + app: + name: ${APP_NAME:smarketing-content} + version: "1.0.0-MVP" + description: "AI 마케팅 서비스 MVP - content" \ No newline at end of file diff --git a/smarketing-java/member/src/main/resources/application.yml b/smarketing-java/member/src/main/resources/application.yml index 6f1de12..80c2281 100644 --- a/smarketing-java/member/src/main/resources/application.yml +++ b/smarketing-java/member/src/main/resources/application.yml @@ -31,3 +31,21 @@ jwt: logging: level: com.won.smarketing: ${LOG_LEVEL:DEBUG} + +management: + endpoints: + web: + exposure: + include: health,info + base-path: /actuator + endpoint: + health: + show-details: always + info: + enabled: true + +info: + app: + name: ${APP_NAME:smarketing-member} + version: "1.0.0-MVP" + description: "AI 마케팅 서비스 MVP - member" \ No newline at end of file diff --git a/smarketing-java/store/src/main/resources/application.yml b/smarketing-java/store/src/main/resources/application.yml index 5a50cbc..bd9d023 100644 --- a/smarketing-java/store/src/main/resources/application.yml +++ b/smarketing-java/store/src/main/resources/application.yml @@ -46,3 +46,21 @@ azure: menu-images: ${AZURE_STORAGE_MENU_CONTAINER:smarketing-menu-images} store-images: ${AZURE_STORAGE_STORE_CONTAINER:smarketing-store-images} max-file-size: ${AZURE_STORAGE_MAX_FILE_SIZE:10485760} # 10MB + +management: + endpoints: + web: + exposure: + include: health,info + base-path: /actuator + endpoint: + health: + show-details: always + info: + enabled: true + +info: + app: + name: ${APP_NAME:smarketing-content} + version: "1.0.0-MVP" + description: "AI 마케팅 서비스 MVP - content" \ No newline at end of file From ec8d0e0aea852f566fd3ffe0ae9f527cb7ea2e42 Mon Sep 17 00:00:00 2001 From: OhSeongRak Date: Tue, 17 Jun 2025 11:24:37 +0900 Subject: [PATCH 17/31] =?UTF-8?q?refactor:=20Jenkinsfile=20-=20Dockerfile?= =?UTF-8?q?=20=EA=B2=BD=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- smarketing-ai/deployment/Jenkinsfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/smarketing-ai/deployment/Jenkinsfile b/smarketing-ai/deployment/Jenkinsfile index 5734088..6a85a4f 100644 --- a/smarketing-ai/deployment/Jenkinsfile +++ b/smarketing-ai/deployment/Jenkinsfile @@ -86,7 +86,7 @@ podTemplate( # Docker 이미지 빌드 podman build \ - -f deployment/container/Dockerfile \ + -f deployment/Dockerfile \ -t ${props.registry}/${props.image_org}/smarketing-ai:${imageTag} . # 이미지 푸시 From 8562c1506291dd328d6eca4c78af3c6f62b8ed2e Mon Sep 17 00:00:00 2001 From: OhSeongRak Date: Tue, 17 Jun 2025 11:27:39 +0900 Subject: [PATCH 18/31] =?UTF-8?q?refactor:=20Jenkinsfile=20-=20Dockerfile?= =?UTF-8?q?=20=EA=B2=BD=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- smarketing-ai/deployment/Jenkinsfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/smarketing-ai/deployment/Jenkinsfile b/smarketing-ai/deployment/Jenkinsfile index 6a85a4f..4896d33 100644 --- a/smarketing-ai/deployment/Jenkinsfile +++ b/smarketing-ai/deployment/Jenkinsfile @@ -86,7 +86,7 @@ podTemplate( # Docker 이미지 빌드 podman build \ - -f deployment/Dockerfile \ + -f smarketing-ai/deployment/Dockerfile \ -t ${props.registry}/${props.image_org}/smarketing-ai:${imageTag} . # 이미지 푸시 From e3d368fcb4dfe4ae678131bd3027360c21b0537f Mon Sep 17 00:00:00 2001 From: OhSeongRak Date: Tue, 17 Jun 2025 11:29:39 +0900 Subject: [PATCH 19/31] =?UTF-8?q?refactor:=20Jenkinsfile=20-=20Check=20Cha?= =?UTF-8?q?nges=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- smarketing-java/deployment/Jenkinsfile | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/smarketing-java/deployment/Jenkinsfile b/smarketing-java/deployment/Jenkinsfile index 79b7055..3e06811 100644 --- a/smarketing-java/deployment/Jenkinsfile +++ b/smarketing-java/deployment/Jenkinsfile @@ -48,15 +48,13 @@ podTemplate( returnStdout: true ).trim() - echo "Changed files: ${changes}" - - if (!changes.contains("smarketing-ai/")) { - echo "No changes in smarketing-ai, skipping build" + if (!changes.contains("smarketing-java/")) { + echo "No changes in smarketing-java, skipping build" currentBuild.result = 'SUCCESS' return } - echo "Changes detected in smarketing-ai, proceeding with build" + echo "Changes detected in smarketing-java, proceeding with build" } } From b2aa6341e7299fcf8352ebdb21a1fe1e70d117e5 Mon Sep 17 00:00:00 2001 From: OhSeongRak Date: Tue, 17 Jun 2025 11:32:12 +0900 Subject: [PATCH 20/31] =?UTF-8?q?refactor:=20Jenkinsfile=20-=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- smarketing-ai/deployment/Jenkinsfile | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/smarketing-ai/deployment/Jenkinsfile b/smarketing-ai/deployment/Jenkinsfile index 4896d33..a37349e 100644 --- a/smarketing-ai/deployment/Jenkinsfile +++ b/smarketing-ai/deployment/Jenkinsfile @@ -132,19 +132,19 @@ podTemplate( export azure_storage_account_key=\$AZURE_STORAGE_ACCOUNT_KEY # manifest 생성 - envsubst < deployment/${manifest}.template > deployment/${manifest} + envsubst < smarketing-ai/deployment/${manifest}.template > smarketing-ai/deployment/${manifest} echo "Generated manifest file:" - cat deployment/${manifest} + cat smarketing-ai/deployment/${manifest} """ } } container('azure-cli') { sh """ - kubectl apply -f deployment/${manifest} + kubectl apply -f smarketing-ai/deployment/${manifest} echo "Waiting for smarketing deployment to be ready..." - kubectl -n ${namespace} wait --for=condition=available deployment/smarketing --timeout=300s + kubectl -n ${namespace} wait --for=condition=available smarketing-ai/deployment/smarketing --timeout=300s echo "==========================================" echo "Getting LoadBalancer External IP..." From 6373c894dc30e7140303024804c8796a7804d1bd Mon Sep 17 00:00:00 2001 From: OhSeongRak Date: Tue, 17 Jun 2025 12:56:05 +0900 Subject: [PATCH 21/31] =?UTF-8?q?refactor:=20Dockerfile=20-=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- smarketing-ai/deployment/Dockerfile | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/smarketing-ai/deployment/Dockerfile b/smarketing-ai/deployment/Dockerfile index 223ed21..6808aa8 100644 --- a/smarketing-ai/deployment/Dockerfile +++ b/smarketing-ai/deployment/Dockerfile @@ -2,11 +2,12 @@ FROM python:3.11-slim WORKDIR /app -COPY requirements.txt . +# 경로 수정 +COPY smarketing-ai/requirements.txt . RUN pip install --no-cache-dir -r requirements.txt # 애플리케이션 코드 복사 -COPY . . +COPY smarketing-ai/ . # 포트 노출 EXPOSE 5001 From e60a50aaacacc73d58b77dcc65140019a3672f9f Mon Sep 17 00:00:00 2001 From: John Hanzu Kim Date: Tue, 17 Jun 2025 13:02:47 +0900 Subject: [PATCH 22/31] Update deploy.yaml.template --- .../deployment/deploy.yaml.template | 85 ++++++++++++++----- 1 file changed, 66 insertions(+), 19 deletions(-) diff --git a/smarketing-java/deployment/deploy.yaml.template b/smarketing-java/deployment/deploy.yaml.template index d14f15e..92e1068 100644 --- a/smarketing-java/deployment/deploy.yaml.template +++ b/smarketing-java/deployment/deploy.yaml.template @@ -8,10 +8,16 @@ data: ALLOWED_ORIGINS: ${allowed_origins} JPA_DDL_AUTO: update JPA_SHOW_SQL: 'true' - # 🔧 Actuator 보안 설정 추가 - MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE: health,info + # 🔧 강화된 Actuator 설정 + MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE: '*' MANAGEMENT_ENDPOINT_HEALTH_SHOW_DETAILS: always - MANAGEMENT_SECURITY_ENABLED: 'false' + MANAGEMENT_ENDPOINT_HEALTH_ENABLED: 'true' + MANAGEMENT_ENDPOINTS_WEB_BASE_PATH: /actuator + MANAGEMENT_SERVER_PORT: '8080' + # Spring Security 비활성화 (Actuator용) + SPRING_AUTOCONFIGURE_EXCLUDE: org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration + # 또는 Management port를 main port와 동일하게 + MANAGEMENT_SERVER_PORT: '' --- apiVersion: v1 @@ -171,18 +177,29 @@ spec: periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 10 + # 🔧 개선된 Health Check 설정 livenessProbe: httpGet: path: /actuator/health port: 8081 - initialDelaySeconds: 60 + httpHeaders: + - name: Accept + value: application/json + initialDelaySeconds: 120 # 2분으로 증가 periodSeconds: 30 + timeoutSeconds: 10 + failureThreshold: 3 readinessProbe: httpGet: - path: /actuator/health + path: /actuator/health/readiness port: 8081 - initialDelaySeconds: 30 - periodSeconds: 5 + httpHeaders: + - name: Accept + value: application/json + initialDelaySeconds: 60 # 1분으로 증가 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 --- apiVersion: apps/v1 @@ -240,14 +257,24 @@ spec: httpGet: path: /actuator/health port: 8082 - initialDelaySeconds: 60 + httpHeaders: + - name: Accept + value: application/json + initialDelaySeconds: 120 periodSeconds: 30 + timeoutSeconds: 10 + failureThreshold: 3 readinessProbe: httpGet: - path: /actuator/health + path: /actuator/health/readiness port: 8082 - initialDelaySeconds: 30 - periodSeconds: 5 + httpHeaders: + - name: Accept + value: application/json + initialDelaySeconds: 60 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 --- apiVersion: apps/v1 @@ -305,14 +332,24 @@ spec: httpGet: path: /actuator/health port: 8083 - initialDelaySeconds: 60 + httpHeaders: + - name: Accept + value: application/json + initialDelaySeconds: 120 periodSeconds: 30 + timeoutSeconds: 10 + failureThreshold: 3 readinessProbe: httpGet: - path: /actuator/health + path: /actuator/health/readiness port: 8083 - initialDelaySeconds: 30 - periodSeconds: 5 + httpHeaders: + - name: Accept + value: application/json + initialDelaySeconds: 60 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 --- apiVersion: apps/v1 @@ -370,14 +407,24 @@ spec: httpGet: path: /actuator/health port: 8084 - initialDelaySeconds: 60 + httpHeaders: + - name: Accept + value: application/json + initialDelaySeconds: 120 periodSeconds: 30 + timeoutSeconds: 10 + failureThreshold: 3 readinessProbe: httpGet: - path: /actuator/health + path: /actuator/health/readiness port: 8084 - initialDelaySeconds: 30 - periodSeconds: 5 + httpHeaders: + - name: Accept + value: application/json + initialDelaySeconds: 60 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 --- # Services From b5edce9ed0374098d7d783cdf681616343414945 Mon Sep 17 00:00:00 2001 From: OhSeongRak Date: Tue, 17 Jun 2025 13:09:41 +0900 Subject: [PATCH 23/31] =?UTF-8?q?refactor:=20key=20=EB=B3=80=EC=88=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- smarketing-ai/deployment/Jenkinsfile | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/smarketing-ai/deployment/Jenkinsfile b/smarketing-ai/deployment/Jenkinsfile index a37349e..ef25948 100644 --- a/smarketing-ai/deployment/Jenkinsfile +++ b/smarketing-ai/deployment/Jenkinsfile @@ -27,7 +27,7 @@ podTemplate( stage("Get Source") { checkout scm - props = readProperties file: "smarketing-ai/deployment/deploy_env_vars" + props = readProperties file: "deployment/deploy_env_vars" namespace = "${props.namespace}" echo "Registry: ${props.registry}" @@ -86,7 +86,7 @@ podTemplate( # Docker 이미지 빌드 podman build \ - -f smarketing-ai/deployment/Dockerfile \ + -f deployment/container/Dockerfile \ -t ${props.registry}/${props.image_org}/smarketing-ai:${imageTag} . # 이미지 푸시 @@ -132,19 +132,19 @@ podTemplate( export azure_storage_account_key=\$AZURE_STORAGE_ACCOUNT_KEY # manifest 생성 - envsubst < smarketing-ai/deployment/${manifest}.template > smarketing-ai/deployment/${manifest} + envsubst < deployment/${manifest}.template > deployment/${manifest} echo "Generated manifest file:" - cat smarketing-ai/deployment/${manifest} + cat deployment/${manifest} """ } } container('azure-cli') { sh """ - kubectl apply -f smarketing-ai/deployment/${manifest} + kubectl apply -f deployment/${manifest} echo "Waiting for smarketing deployment to be ready..." - kubectl -n ${namespace} wait --for=condition=available smarketing-ai/deployment/smarketing --timeout=300s + kubectl -n ${namespace} wait --for=condition=available deployment/smarketing --timeout=300s echo "==========================================" echo "Getting LoadBalancer External IP..." From 1cfbcadba964931b1045aaddd5ddd431e440170e Mon Sep 17 00:00:00 2001 From: OhSeongRak Date: Tue, 17 Jun 2025 13:14:42 +0900 Subject: [PATCH 24/31] =?UTF-8?q?refactor:=20key=20=EB=B3=80=EC=88=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- smarketing-ai/deployment/Jenkinsfile | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/smarketing-ai/deployment/Jenkinsfile b/smarketing-ai/deployment/Jenkinsfile index ef25948..6b71b4b 100644 --- a/smarketing-ai/deployment/Jenkinsfile +++ b/smarketing-ai/deployment/Jenkinsfile @@ -27,7 +27,7 @@ podTemplate( stage("Get Source") { checkout scm - props = readProperties file: "deployment/deploy_env_vars" + props = readProperties file: "smarketing-ai/deployment/deploy_env_vars" namespace = "${props.namespace}" echo "Registry: ${props.registry}" @@ -86,7 +86,7 @@ podTemplate( # Docker 이미지 빌드 podman build \ - -f deployment/container/Dockerfile \ + -f smarketing-ai/deployment/Dockerfile \ -t ${props.registry}/${props.image_org}/smarketing-ai:${imageTag} . # 이미지 푸시 @@ -101,11 +101,11 @@ podTemplate( stage('Generate & Apply Manifest') { container('envsubst') { withCredentials([ - string(credentialsId: 'secret-key', variable: 'SECRET_KEY'), - string(credentialsId: 'claude-api-key', variable: 'CLAUDE_API_KEY'), - string(credentialsId: 'openai-api-key', variable: 'OPENAI_API_KEY'), - string(credentialsId: 'azure-storage-account-name', variable: 'AZURE_STORAGE_ACCOUNT_NAME'), - string(credentialsId: 'azure-storage-account-key', variable: 'AZURE_STORAGE_ACCOUNT_KEY') + string(credentialsId: 'SECRET_KEY', variable: 'SECRET_KEY'), + string(credentialsId: 'CLAUDE_API_KEY', variable: 'CLAUDE_API_KEY'), + string(credentialsId: 'OPENAI_API_KEY', variable: 'OPENAI_API_KEY'), + string(credentialsId: 'AZURE_STORAGE_ACCOUNT_NAME', variable: 'AZURE_STORAGE_ACCOUNT_NAME'), + string(credentialsId: 'AZURE_STORAGE_ACCOUNT_KEY', variable: 'AZURE_STORAGE_ACCOUNT_KEY') ]) { sh """ export namespace=${namespace} @@ -132,19 +132,19 @@ podTemplate( export azure_storage_account_key=\$AZURE_STORAGE_ACCOUNT_KEY # manifest 생성 - envsubst < deployment/${manifest}.template > deployment/${manifest} + envsubst < smarketing-ai/deployment/${manifest}.template > smarketing-ai/deployment/${manifest} echo "Generated manifest file:" - cat deployment/${manifest} + cat smarketing-ai/deployment/${manifest} """ } } container('azure-cli') { sh """ - kubectl apply -f deployment/${manifest} + kubectl apply -f smarketing-ai/deployment/${manifest} echo "Waiting for smarketing deployment to be ready..." - kubectl -n ${namespace} wait --for=condition=available deployment/smarketing --timeout=300s + kubectl -n ${namespace} wait --for=condition=available smarketing-ai/smarketing --timeout=300s echo "==========================================" echo "Getting LoadBalancer External IP..." From e50e4b4da4d5c666af497549e776ed1adf46d33f Mon Sep 17 00:00:00 2001 From: OhSeongRak Date: Tue, 17 Jun 2025 13:18:49 +0900 Subject: [PATCH 25/31] =?UTF-8?q?refactor:=20Jenkinsfile=20-=20Check=20Cha?= =?UTF-8?q?nges=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- smarketing-ai/deployment/Jenkinsfile | 4 ++-- smarketing-java/deployment/Jenkinsfile | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/smarketing-ai/deployment/Jenkinsfile b/smarketing-ai/deployment/Jenkinsfile index 6b71b4b..9035342 100644 --- a/smarketing-ai/deployment/Jenkinsfile +++ b/smarketing-ai/deployment/Jenkinsfile @@ -47,7 +47,7 @@ podTemplate( if (!changes.contains("smarketing-ai/")) { echo "No changes in smarketing-ai, skipping build" currentBuild.result = 'SUCCESS' - return + error("Stopping pipeline - no changes detected") } echo "Changes detected in smarketing-ai, proceeding with build" @@ -144,7 +144,7 @@ podTemplate( kubectl apply -f smarketing-ai/deployment/${manifest} echo "Waiting for smarketing deployment to be ready..." - kubectl -n ${namespace} wait --for=condition=available smarketing-ai/smarketing --timeout=300s + kubectl -n ${namespace} wait --for=condition=available smarketing-ai/deployment/smarketing --timeout=300s echo "==========================================" echo "Getting LoadBalancer External IP..." diff --git a/smarketing-java/deployment/Jenkinsfile b/smarketing-java/deployment/Jenkinsfile index 3e06811..ce96650 100644 --- a/smarketing-java/deployment/Jenkinsfile +++ b/smarketing-java/deployment/Jenkinsfile @@ -51,7 +51,7 @@ podTemplate( if (!changes.contains("smarketing-java/")) { echo "No changes in smarketing-java, skipping build" currentBuild.result = 'SUCCESS' - return + error("Stopping pipeline - no changes detected") } echo "Changes detected in smarketing-java, proceeding with build" From f3fc5bc39ecb79404bacb4df1b3bd27a69bba53d Mon Sep 17 00:00:00 2001 From: OhSeongRak Date: Tue, 17 Jun 2025 13:25:18 +0900 Subject: [PATCH 26/31] =?UTF-8?q?refactor:=20container('azure-cli')=20-=20?= =?UTF-8?q?wait=20=EB=AA=85=EB=A0=B9=EC=96=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- smarketing-ai/deployment/Jenkinsfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/smarketing-ai/deployment/Jenkinsfile b/smarketing-ai/deployment/Jenkinsfile index 9035342..e55f855 100644 --- a/smarketing-ai/deployment/Jenkinsfile +++ b/smarketing-ai/deployment/Jenkinsfile @@ -144,7 +144,7 @@ podTemplate( kubectl apply -f smarketing-ai/deployment/${manifest} echo "Waiting for smarketing deployment to be ready..." - kubectl -n ${namespace} wait --for=condition=available smarketing-ai/deployment/smarketing --timeout=300s + kubectl -n ${namespace} wait --for=condition=available deployment/smarketing --timeout=300s echo "==========================================" echo "Getting LoadBalancer External IP..." From e2d98e4dc9a408199b540723ba10e60950daff1c Mon Sep 17 00:00:00 2001 From: yuhalog Date: Tue, 17 Jun 2025 13:31:38 +0900 Subject: [PATCH 27/31] add : SecurityConfig - actuator url --- .../services/marketing_tip_service.py | 29 +++++++++++++++---- .../service/MarketingTipService.java | 6 ++-- .../common/config/SecurityConfig.java | 4 +-- 3 files changed, 30 insertions(+), 9 deletions(-) diff --git a/smarketing-ai/services/marketing_tip_service.py b/smarketing-ai/services/marketing_tip_service.py index db5526a..956eaf7 100644 --- a/smarketing-ai/services/marketing_tip_service.py +++ b/smarketing-ai/services/marketing_tip_service.py @@ -101,7 +101,8 @@ class MarketingTipService: 당신의 임무는 매장 정보를 바탕으로, 적은 비용으로 효과를 낼 수 있는 현실적이고 실행 가능한 마케팅 팁을 제안하는 것입니다. 지역성, 지역의 현재 날씨 확인하고, 현재 트렌드까지 고려해주세요. -소상공인을 위한 실용적인 마케팅 팁을 생성해주세요. +소상공인을 위한 현실적이고 바로 실행할 수 있는 실용적인 마케팅 팁을 생성해주세요. +협업보다는 할인, 포스팅 등 당장 실현 가능한 현실적인 방법을 추천해주세요. 매장 정보: - 매장명: {store_name} @@ -123,7 +124,7 @@ class MarketingTipService: prompt += """ 아래 조건을 모두 충족하는 마케팅 팁을 하나 생성해주세요: -1. **실행 가능성**: 소상공인이 실제로 적용할 수 있는 현실적인 방법 +1. **실행 가능성**: 소상공인이 실제로 바로 적용할 수 있는 현실적인 방법 2. **비용 효율성**: 적은 비용으로 높은 효과를 기대할 수 있는 전략 3. **구체성**: 실행 단계가 명확하고 구체적일 것 4. **시의성**: 현재 계절, 유행, 트렌드를 반영 @@ -131,9 +132,7 @@ class MarketingTipService: 응답 형식 (300자 내외, 간결하게): html 형식으로 출력 -핵심 마케팅 팁은 제목없이 한번 더 상단에 보여주세요 -부제목과 내용은 분리해서 출력 -아래의 부제목 앞에는 이모지 포함 + - 핵심 마케팅 팁 (1개) - 실행 방법 (1개) - 예상 비용과 기대 효과 @@ -141,6 +140,26 @@ html 형식으로 출력 - 참고했던 실제 성공한 마케팅 - 오늘의 응원의 문장 (간결하게 1개) +아래 형식대로 그대로 출력해주세요! + +

✨ 핵심 마케팅 팁

+

[여기에 새로운 핵심 마케팅 팁 작성]

+ +

🚀 실행 방법

+

[여기에 새로운 실행 방법 내용 작성]

+ +

💰 예상 비용과 기대 효과

+

[여기에 새로운 비용/효과 내용 작성]

+ +

⚠️ 주의사항

+

[여기에 새로운 주의사항 내용 작성]

+ +

📈 참고했던 실제 성공한 마케팅

+

[여기에 새로운 참고 사례 내용 작성]

+ +

🙌 오늘의 응원의 문장

+

[여기에 응원의 문장 작성]

+ 심호흡하고, 단계별로 차근차근 생각해서 정확하고 실현 가능한 아이디어를 제시해주세요. """ diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java index 49b2801..a960a0a 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java @@ -103,9 +103,12 @@ public class MarketingTipService implements MarketingTipUseCase { String aiGeneratedTip = aiTipGenerator.generateTip(storeWithMenuData); log.debug("AI 팁 생성 완료: {}", aiGeneratedTip.substring(0, Math.min(50, aiGeneratedTip.length()))); + String tipSummary = generateTipSummary(aiGeneratedTip); + // 도메인 객체 생성 및 저장 MarketingTip marketingTip = MarketingTip.builder() .storeId(storeWithMenuData.getStoreData().getStoreId()) + .tipSummary(tipSummary) .tipContent(aiGeneratedTip) .storeWithMenuData(storeWithMenuData) .createdAt(LocalDateTime.now()) @@ -122,11 +125,10 @@ public class MarketingTipService implements MarketingTipUseCase { * 마케팅 팁을 응답 DTO로 변환 (전체 내용 포함) */ private MarketingTipResponse convertToResponse(MarketingTip marketingTip, StoreData storeData, boolean isRecentlyCreated) { - String tipSummary = generateTipSummary(marketingTip.getTipContent()); return MarketingTipResponse.builder() .tipId(marketingTip.getId().getValue()) - .tipSummary(tipSummary) + .tipSummary(marketingTip.getTipSummary()) .tipContent(marketingTip.getTipContent()) // 🆕 전체 내용 포함 .storeInfo(MarketingTipResponse.StoreInfo.builder() .storeName(storeData.getStoreName()) diff --git a/smarketing-java/common/src/main/java/com/won/smarketing/common/config/SecurityConfig.java b/smarketing-java/common/src/main/java/com/won/smarketing/common/config/SecurityConfig.java index 5c61143..7b8f4f2 100644 --- a/smarketing-java/common/src/main/java/com/won/smarketing/common/config/SecurityConfig.java +++ b/smarketing-java/common/src/main/java/com/won/smarketing/common/config/SecurityConfig.java @@ -44,8 +44,8 @@ public class SecurityConfig { .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/**", "/api/member/register", "/api/member/check-duplicate/**", - "/api/member/validate-password", "/swagger-ui/**", "/v3/api-docs/**", - "/swagger-resources/**", "/webjars/**").permitAll() + "/api/member/validate-password", "/swagger-ui/**", "/v3/api-docs/**", + "/swagger-resources/**", "/webjars/**", "/actuator/**", "/health/**", "/error").permitAll() .anyRequest().authenticated() ) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); From f9a5007cfd69e703a0e02013d9319d7f2289d016 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=84=9C=EC=9D=80?= <61147083+BangSun98@users.noreply.github.com> Date: Tue, 17 Jun 2025 13:51:20 +0900 Subject: [PATCH 28/31] fix prompt --- smarketing-ai/services/sns_content_service.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/smarketing-ai/services/sns_content_service.py b/smarketing-ai/services/sns_content_service.py index 680a1e7..16fd8ba 100644 --- a/smarketing-ai/services/sns_content_service.py +++ b/smarketing-ai/services/sns_content_service.py @@ -1714,8 +1714,7 @@ class SnsContentService: 6. 해시태그는 본문과 자연스럽게 연결되도록 배치 **필수 요구사항:** -{request.requirement #or '고객의 관심을 끌고 방문을 유도하는 매력적인 게시물' -} +{request.requirement} or '고객의 관심을 끌고 방문을 유도하는 매력적인 게시물' 인스타그램 사용자들이 "저장하고 싶다", "친구에게 공유하고 싶다"라고 생각할 만한 매력적인 게시물을 작성해주세요. 필수 요구사항을 반드시 참고하여 작성해주세요. @@ -1788,9 +1787,7 @@ class SnsContentService: - 각 이미지 태그 다음 줄에 이미지 설명 문구 작성 **필수 요구사항:** -{request.requirement - # or '유용한 정보를 제공하여 방문을 유도하는 신뢰성 있는 후기' -} +{request.requirement} or '유용한 정보를 제공하여 방문을 유도하는 신뢰성 있는 후기' 네이버 검색에서 상위 노출되고, 실제로 도움이 되는 정보를 제공하는 블로그 포스트를 작성해주세요. 필수 요구사항을 반드시 참고하여 작성해주세요. From 14c5164c41a1ef6a8edf067ceb7abf1acc5e28c8 Mon Sep 17 00:00:00 2001 From: yuhalog Date: Tue, 17 Jun 2025 14:05:21 +0900 Subject: [PATCH 29/31] feat: Marketing Tip Summary --- .../services/marketing_tip_service.py | 11 +- .../service/MarketingTipService.java | 160 +++++++++++++++++- 2 files changed, 157 insertions(+), 14 deletions(-) diff --git a/smarketing-ai/services/marketing_tip_service.py b/smarketing-ai/services/marketing_tip_service.py index 956eaf7..deceb3c 100644 --- a/smarketing-ai/services/marketing_tip_service.py +++ b/smarketing-ai/services/marketing_tip_service.py @@ -102,7 +102,7 @@ class MarketingTipService: 당신의 임무는 매장 정보를 바탕으로, 적은 비용으로 효과를 낼 수 있는 현실적이고 실행 가능한 마케팅 팁을 제안하는 것입니다. 지역성, 지역의 현재 날씨 확인하고, 현재 트렌드까지 고려해주세요. 소상공인을 위한 현실적이고 바로 실행할 수 있는 실용적인 마케팅 팁을 생성해주세요. -협업보다는 할인, 포스팅 등 당장 실현 가능한 현실적인 방법을 추천해주세요. +협업보다는 할인, 포스팅 등 당장 실현 가능한 현실적이면서도 창의적인 방법을 추천해주세요. 매장 정보: - 매장명: {store_name} @@ -130,9 +130,7 @@ class MarketingTipService: 4. **시의성**: 현재 계절, 유행, 트렌드를 반영 5. **지역성**: 지역 특성 및 현재 날씨를 고려할 것 -응답 형식 (300자 내외, 간결하게): -html 형식으로 출력 - +출력해야할 내용: - 핵심 마케팅 팁 (1개) - 실행 방법 (1개) - 예상 비용과 기대 효과 @@ -140,7 +138,8 @@ html 형식으로 출력 - 참고했던 실제 성공한 마케팅 - 오늘의 응원의 문장 (간결하게 1개) -아래 형식대로 그대로 출력해주세요! +아래 HTML 템플릿 형식으로 응답해주세요.

태그는 절대 변경하지 말고,

태그 내용만 새로 작성해주세요 +

태그 내용 외에 다른 내용은 절대 넣지 마세요 :

✨ 핵심 마케팅 팁

[여기에 새로운 핵심 마케팅 팁 작성]

@@ -155,7 +154,7 @@ html 형식으로 출력

[여기에 새로운 주의사항 내용 작성]

📈 참고했던 실제 성공한 마케팅

-

[여기에 새로운 참고 사례 내용 작성]

+

[여기에 새로운 참고 사례 내용 작성, 존재하지 않는 사례는 절대 참고하지 말고, 실제 존재하는 마케팅 성공 사례로만 작성. 참고했던 존재하는 url로 함께 표기]

🙌 오늘의 응원의 문장

[여기에 응원의 문장 작성]

diff --git a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java index a960a0a..ee19059 100644 --- a/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java +++ b/smarketing-java/ai-recommend/src/main/java/com/won/smarketing/recommend/application/service/MarketingTipService.java @@ -142,23 +142,167 @@ public class MarketingTipService implements MarketingTipUseCase { } /** - * 마케팅 팁 요약 생성 (첫 50자 또는 첫 번째 문장) + * 마케팅 팁 요약 생성 (핵심 마케팅 팁 섹션에서 첫 번째 문장 추출) + * + * @param fullContent AI로 생성된 전체 마케팅 팁 HTML 콘텐츠 + * @return 핵심 마케팅 팁의 첫 번째 문장 */ private String generateTipSummary(String fullContent) { if (fullContent == null || fullContent.trim().isEmpty()) { return "마케팅 팁이 생성되었습니다."; } - // 첫 번째 문장으로 요약 (마침표 기준) - String[] sentences = fullContent.split("[.!?]"); - String firstSentence = sentences.length > 0 ? sentences[0].trim() : fullContent; + try { + // 1. "✨ 핵심 마케팅 팁" 섹션 추출 + String coreSection = extractCoreMarketingTipSection(fullContent); - // 50자 제한 - if (firstSentence.length() > 50) { - return firstSentence.substring(0, 47) + "..."; + if (coreSection != null && !coreSection.trim().isEmpty()) { + // 2. HTML 태그 제거 + String cleanText = removeHtmlTags(coreSection); + + // 3. 첫 번째 의미있는 문장 추출 + String summary = extractFirstMeaningfulSentence(cleanText); + + // 4. 길이 제한 (100자 이내) + if (summary.length() > 100) { + summary = summary.substring(0, 97) + "..."; + } + + return summary; + } + + // 핵심 팁 섹션을 찾지 못한 경우 fallback 처리 + return extractFallbackSummary(fullContent); + + } catch (Exception e) { + log.warn("마케팅 팁 요약 생성 중 오류 발생, 기본 메시지 반환: {}", e.getMessage()); + return "맞춤형 마케팅 팁이 생성되었습니다."; + } + } + + /** + * "✨ 핵심 마케팅 팁" 섹션 추출 + */ + private String extractCoreMarketingTipSection(String fullContent) { + // 핵심 마케팅 팁 섹션 시작 패턴들 + String[] corePatterns = { + "✨ 핵심 마케팅 팁", + "

✨ 핵심 마케팅 팁

", + "핵심 마케팅 팁" + }; + + // 다음 섹션 시작 패턴들 + String[] nextSectionPatterns = { + "🚀 실행 방법", + "

🚀 실행 방법

", + "💰 예상 비용", + "

💰 예상 비용" + }; + + for (String pattern : corePatterns) { + int startIndex = fullContent.indexOf(pattern); + if (startIndex != -1) { + // 패턴 뒤부터 시작 + int contentStart = startIndex + pattern.length(); + + // 다음 섹션까지의 내용 추출 + int endIndex = fullContent.length(); + for (String nextPattern : nextSectionPatterns) { + int nextIndex = fullContent.indexOf(nextPattern, contentStart); + if (nextIndex != -1 && nextIndex < endIndex) { + endIndex = nextIndex; + } + } + + return fullContent.substring(contentStart, endIndex).trim(); + } } - return firstSentence; + return null; + } + + /** + * HTML 태그 제거 + */ + private String removeHtmlTags(String htmlText) { + if (htmlText == null) return ""; + + return htmlText + .replaceAll("<[^>]+>", "") // HTML 태그 제거 + .replaceAll(" ", " ") // HTML 엔티티 처리 + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("&", "&") + .replaceAll("\\s+", " ") // 연속된 공백을 하나로 + .trim(); + } + + /** + * 첫 번째 의미있는 문장 추출 + */ + private String extractFirstMeaningfulSentence(String cleanText) { + if (cleanText == null || cleanText.trim().isEmpty()) { + return "마케팅 팁이 생성되었습니다."; + } + + // 문장 분할 (마침표, 느낌표, 물음표 기준) + String[] sentences = cleanText.split("[.!?]"); + + for (String sentence : sentences) { + String trimmed = sentence.trim(); + + // 의미있는 문장인지 확인 (10자 이상, 특수문자만으로 구성되지 않음) + if (trimmed.length() >= 10 && + !trimmed.matches("^[\\s\\p{Punct}]*$") && // 공백과 구두점만으로 구성되지 않음 + !isOnlyEmojisOrSymbols(trimmed)) { // 이모지나 기호만으로 구성되지 않음 + + // 문장 끝에 마침표 추가 (없는 경우) + if (!trimmed.endsWith(".") && !trimmed.endsWith("!") && !trimmed.endsWith("?")) { + trimmed += "."; + } + + return trimmed; + } + } + + // 의미있는 문장을 찾지 못한 경우 원본의 처음 50자 반환 + if (cleanText.length() > 50) { + return cleanText.substring(0, 47) + "..."; + } + + return cleanText; + } + + /** + * 이모지나 기호만으로 구성되었는지 확인 + */ + private boolean isOnlyEmojisOrSymbols(String text) { + // 한글, 영문, 숫자가 포함되어 있으면 의미있는 텍스트로 판단 + return !text.matches(".*[\\p{L}\\p{N}].*"); + } + + /** + * 핵심 팁 섹션을 찾지 못한 경우 대체 요약 생성 + */ + private String extractFallbackSummary(String fullContent) { + // HTML 태그 제거 후 첫 번째 의미있는 문장 찾기 + String cleanContent = removeHtmlTags(fullContent); + + // 첫 번째 문단에서 의미있는 문장 추출 + String[] paragraphs = cleanContent.split("\\n\\n"); + + for (String paragraph : paragraphs) { + String trimmed = paragraph.trim(); + if (trimmed.length() >= 20) { // 충분히 긴 문단 + String summary = extractFirstMeaningfulSentence(trimmed); + if (summary.length() >= 10) { + return summary; + } + } + } + + // 모든 방법이 실패한 경우 기본 메시지 + return "개인화된 마케팅 팁이 생성되었습니다."; } /** From 70ea983da46fff596f04995314c087c63e283ca7 Mon Sep 17 00:00:00 2001 From: yuhalog Date: Tue, 17 Jun 2025 14:43:23 +0900 Subject: [PATCH 30/31] refactor: actuator health --- .../src/main/resources/application.yml | 5 + .../service/PosterContentService.java | 30 ++-- .../external/ClaudeAiPosterGenerator.java | 86 ---------- .../external/PythonAiPosterGenerator.java | 151 ++++++++++++++++++ .../src/main/resources/application.yml | 5 + .../member/src/main/resources/application.yml | 5 + .../store/src/main/resources/application.yml | 5 + 7 files changed, 181 insertions(+), 106 deletions(-) delete mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java create mode 100644 smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/PythonAiPosterGenerator.java diff --git a/smarketing-java/ai-recommend/src/main/resources/application.yml b/smarketing-java/ai-recommend/src/main/resources/application.yml index 72aa217..d392c82 100644 --- a/smarketing-java/ai-recommend/src/main/resources/application.yml +++ b/smarketing-java/ai-recommend/src/main/resources/application.yml @@ -50,6 +50,11 @@ management: show-details: always info: enabled: true + health: + livenessState: + enabled: true + readinessState: + enabled: true logging: level: diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/PosterContentService.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/PosterContentService.java index e89b5c5..55a1b19 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/PosterContentService.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/application/service/PosterContentService.java @@ -16,6 +16,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; +import java.util.HashMap; import java.util.Map; /** @@ -32,25 +33,20 @@ public class PosterContentService implements PosterContentUseCase { /** * 포스터 콘텐츠 생성 - * + * * @param request 포스터 콘텐츠 생성 요청 * @return 생성된 포스터 콘텐츠 정보 */ @Override @Transactional public PosterContentCreateResponse generatePosterContent(PosterContentCreateRequest request) { - // AI를 사용하여 포스터 생성 + String generatedPoster = aiPosterGenerator.generatePoster(request); - - // 다양한 사이즈의 포스터 생성 - Map posterSizes = aiPosterGenerator.generatePosterSizes(generatedPoster); // 생성 조건 정보 구성 CreationConditions conditions = CreationConditions.builder() .category(request.getCategory()) .requirement(request.getRequirement()) - // .toneAndManner(request.getToneAndManner()) - // .emotionIntensity(request.getEmotionIntensity()) .eventName(request.getEventName()) .startDate(request.getStartDate()) .endDate(request.getEndDate()) @@ -62,47 +58,41 @@ public class PosterContentService implements PosterContentUseCase { .contentType(ContentType.POSTER.name()) .title(request.getTitle()) .posterImage(generatedPoster) - .posterSizes(posterSizes) + .posterSizes(new HashMap<>()) // 빈 맵 반환 (사이즈 변환 안함) .status(ContentStatus.DRAFT.name()) - //.createdAt(LocalDateTime.now()) .build(); } /** * 포스터 콘텐츠 저장 - * + * * @param request 포스터 콘텐츠 저장 요청 */ @Override @Transactional public void savePosterContent(PosterContentSaveRequest request) { - // 생성 조건 정보 구성 + // 생성 조건 구성 CreationConditions conditions = CreationConditions.builder() .category(request.getCategory()) .requirement(request.getRequirement()) - // .toneAndManner(request.getToneAndManner()) - // .emotionIntensity(request.getEmotionIntensity()) .eventName(request.getEventName()) .startDate(request.getStartDate()) .endDate(request.getEndDate()) .photoStyle(request.getPhotoStyle()) .build(); - // 콘텐츠 엔티티 생성 및 저장 + // 콘텐츠 엔티티 생성 Content content = Content.builder() .contentType(ContentType.POSTER) - .platform(Platform.GENERAL) // 포스터는 범용 .title(request.getTitle()) - .content(null) // 포스터는 이미지가 주 콘텐츠 - .hashtags(null) + .content(request.getContent()) .images(request.getImages()) .status(ContentStatus.PUBLISHED) .creationConditions(conditions) .storeId(request.getStoreId()) - .createdAt(LocalDateTime.now()) - .updatedAt(LocalDateTime.now()) .build(); + // 저장 contentRepository.save(content); } -} +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java deleted file mode 100644 index 7495966..0000000 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java +++ /dev/null @@ -1,86 +0,0 @@ -// marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/ClaudeAiPosterGenerator.java -package com.won.smarketing.content.infrastructure.external; - -import com.won.smarketing.content.domain.service.AiPosterGenerator; // 도메인 인터페이스 import -import com.won.smarketing.content.presentation.dto.PosterContentCreateRequest; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -import java.util.HashMap; -import java.util.Map; - -/** - * Claude AI를 활용한 포스터 생성 구현체 - * Clean Architecture의 Infrastructure Layer에 위치 - */ -@Component -@RequiredArgsConstructor -@Slf4j -public class ClaudeAiPosterGenerator implements AiPosterGenerator { - - /** - * 포스터 생성 - * - * @param request 포스터 생성 요청 - * @return 생성된 포스터 이미지 URL - */ - @Override - public String generatePoster(PosterContentCreateRequest request) { - try { - // Claude AI API 호출 로직 - String prompt = buildPosterPrompt(request); - - // TODO: 실제 Claude AI API 호출 - // 현재는 더미 데이터 반환 - return generateDummyPosterUrl(request.getTitle()); - - } catch (Exception e) { - log.error("AI 포스터 생성 실패: {}", e.getMessage(), e); - return generateFallbackPosterUrl(); - } - } - - /** - * 다양한 사이즈의 포스터 생성 - * - * @param baseImage 기본 이미지 - * @return 사이즈별 포스터 URL 맵 - */ - @Override - public Map generatePosterSizes(String baseImage) { - Map sizes = new HashMap<>(); - - // 다양한 사이즈 생성 (더미 구현) - sizes.put("instagram_square", baseImage + "_1080x1080.jpg"); - sizes.put("instagram_story", baseImage + "_1080x1920.jpg"); - sizes.put("facebook_post", baseImage + "_1200x630.jpg"); - sizes.put("a4_poster", baseImage + "_2480x3508.jpg"); - - return sizes; - } - - private String buildPosterPrompt(PosterContentCreateRequest request) { - StringBuilder prompt = new StringBuilder(); - prompt.append("포스터 제목: ").append(request.getTitle()).append("\n"); - prompt.append("카테고리: ").append(request.getCategory()).append("\n"); - - if (request.getRequirement() != null) { - prompt.append("요구사항: ").append(request.getRequirement()).append("\n"); - } - - if (request.getToneAndManner() != null) { - prompt.append("톤앤매너: ").append(request.getToneAndManner()).append("\n"); - } - - return prompt.toString(); - } - - private String generateDummyPosterUrl(String title) { - return "https://dummy-ai-service.com/posters/" + title.hashCode() + ".jpg"; - } - - private String generateFallbackPosterUrl() { - return "https://dummy-ai-service.com/posters/fallback.jpg"; - } -} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/PythonAiPosterGenerator.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/PythonAiPosterGenerator.java new file mode 100644 index 0000000..2318ce0 --- /dev/null +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/PythonAiPosterGenerator.java @@ -0,0 +1,151 @@ +package com.won.smarketing.content.infrastructure.external; + +import com.won.smarketing.content.domain.service.AiPosterGenerator; // 도메인 인터페이스 import +import com.won.smarketing.content.presentation.dto.PosterContentCreateRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +import java.time.Duration; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.Map; + +/** + * Claude AI를 활용한 포스터 생성 구현체 + * Clean Architecture의 Infrastructure Layer에 위치 + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class PythonAiPosterGenerator implements AiPosterGenerator { + + private final WebClient webClient; + + @Value("${external.ai-service.base-url:http://20.249.139.88:5001}") + private String aiServiceBaseUrl; + + /** + * 포스터 생성 - Python AI 서비스 호출 + * + * @param request 포스터 생성 요청 + * @return 생성된 포스터 이미지 URL + */ + @Override + public String generatePoster(PosterContentCreateRequest request) { + try { + log.info("Python AI 포스터 서비스 호출: {}/api/ai/poster", aiServiceBaseUrl); + + // 요청 데이터 구성 + Map requestBody = buildRequestBody(request); + + log.debug("포스터 생성 요청 데이터: {}", requestBody); + + // Python AI 서비스 호출 + Map response = webClient + .post() + .uri(aiServiceBaseUrl + "/api/ai/poster") + .header("Content-Type", "application/json") + .bodyValue(requestBody) + .retrieve() + .bodyToMono(Map.class) + .timeout(Duration.ofSeconds(60)) // 포스터 생성은 시간이 오래 걸릴 수 있음 + .block(); + + // 응답에서 content(이미지 URL) 추출 + if (response != null && response.containsKey("content")) { + String imageUrl = (String) response.get("content"); + log.info("AI 포스터 생성 성공: imageUrl={}", imageUrl); + return imageUrl; + } else { + log.warn("AI 포스터 생성 응답에 content가 없음: {}", response); + return generateFallbackPosterUrl(request.getTitle()); + } + + } catch (Exception e) { + log.error("AI 포스터 생성 실패: {}", e.getMessage(), e); + return generateFallbackPosterUrl(request.getTitle()); + } + } + + /** + * 다양한 사이즈의 포스터 생성 (사용하지 않음) + * 1개의 이미지만 생성하므로 빈 맵 반환 + * + * @param baseImage 기본 이미지 URL + * @return 빈 맵 + */ + @Override + public Map generatePosterSizes(String baseImage) { + log.info("포스터 사이즈 변환 기능은 사용하지 않음: baseImage={}", baseImage); + return new HashMap<>(); + } + + /** + * Python AI 서비스 요청 데이터 구성 + * Python 서비스의 PosterContentGetRequest 모델에 맞춤 + */ + private Map buildRequestBody(PosterContentCreateRequest request) { + Map requestBody = new HashMap<>(); + + // 기본 정보 + requestBody.put("title", request.getTitle()); + requestBody.put("category", request.getCategory()); + requestBody.put("contentType", request.getContentType()); + + // 이미지 정보 + if (request.getImages() != null && !request.getImages().isEmpty()) { + requestBody.put("images", request.getImages()); + } + + // 스타일 정보 + if (request.getPhotoStyle() != null) { + requestBody.put("photoStyle", request.getPhotoStyle()); + } + + // 요구사항 + if (request.getRequirement() != null) { + requestBody.put("requirement", request.getRequirement()); + } + + // 톤앤매너 + if (request.getToneAndManner() != null) { + requestBody.put("toneAndManner", request.getToneAndManner()); + } + + // 감정 강도 + if (request.getEmotionIntensity() != null) { + requestBody.put("emotionIntensity", request.getEmotionIntensity()); + } + + // 메뉴명 + if (request.getMenuName() != null) { + requestBody.put("menuName", request.getMenuName()); + } + + // 이벤트 정보 + if (request.getEventName() != null) { + requestBody.put("eventName", request.getEventName()); + } + + // 날짜 정보 (LocalDate를 String으로 변환) + if (request.getStartDate() != null) { + requestBody.put("startDate", request.getStartDate().format(DateTimeFormatter.ISO_LOCAL_DATE)); + } + + if (request.getEndDate() != null) { + requestBody.put("endDate", request.getEndDate().format(DateTimeFormatter.ISO_LOCAL_DATE)); + } + + return requestBody; + } + + /** + * 폴백 포스터 URL 생성 + */ + private String generateFallbackPosterUrl(String title) { + // 기본 포스터 템플릿 URL 반환 + return "https://stdigitalgarage02.blob.core.windows.net/ai-content/fallback-poster.jpg"; + } +} \ No newline at end of file diff --git a/smarketing-java/marketing-content/src/main/resources/application.yml b/smarketing-java/marketing-content/src/main/resources/application.yml index aefce5c..4157124 100644 --- a/smarketing-java/marketing-content/src/main/resources/application.yml +++ b/smarketing-java/marketing-content/src/main/resources/application.yml @@ -49,6 +49,11 @@ management: show-details: always info: enabled: true + health: + livenessState: + enabled: true + readinessState: + enabled: true info: app: diff --git a/smarketing-java/member/src/main/resources/application.yml b/smarketing-java/member/src/main/resources/application.yml index 80c2281..92741bc 100644 --- a/smarketing-java/member/src/main/resources/application.yml +++ b/smarketing-java/member/src/main/resources/application.yml @@ -43,6 +43,11 @@ management: show-details: always info: enabled: true + health: + livenessState: + enabled: true + readinessState: + enabled: true info: app: diff --git a/smarketing-java/store/src/main/resources/application.yml b/smarketing-java/store/src/main/resources/application.yml index bd9d023..18a8934 100644 --- a/smarketing-java/store/src/main/resources/application.yml +++ b/smarketing-java/store/src/main/resources/application.yml @@ -58,6 +58,11 @@ management: show-details: always info: enabled: true + health: + livenessState: + enabled: true + readinessState: + enabled: true info: app: From a8c945ac9d8adb54f1c3ef44271728bc910d74d4 Mon Sep 17 00:00:00 2001 From: yuhalog <62270401+yuhalog@users.noreply.github.com> Date: Tue, 17 Jun 2025 15:08:26 +0900 Subject: [PATCH 31/31] fix: Update PythonAiPosterGenerator.java --- .../infrastructure/external/PythonAiPosterGenerator.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/PythonAiPosterGenerator.java b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/PythonAiPosterGenerator.java index 2318ce0..c166cd6 100644 --- a/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/PythonAiPosterGenerator.java +++ b/smarketing-java/marketing-content/src/main/java/com/won/smarketing/content/infrastructure/external/PythonAiPosterGenerator.java @@ -4,6 +4,7 @@ import com.won.smarketing.content.domain.service.AiPosterGenerator; // 도메인 import com.won.smarketing.content.presentation.dto.PosterContentCreateRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.client.WebClient; @@ -23,7 +24,7 @@ public class PythonAiPosterGenerator implements AiPosterGenerator { private final WebClient webClient; - @Value("${external.ai-service.base-url:http://20.249.139.88:5001}") + @Value("${external.ai-service.base-url}") private String aiServiceBaseUrl; /** @@ -148,4 +149,4 @@ public class PythonAiPosterGenerator implements AiPosterGenerator { // 기본 포스터 템플릿 URL 반환 return "https://stdigitalgarage02.blob.core.windows.net/ai-content/fallback-poster.jpg"; } -} \ No newline at end of file +}