From 6c76f4a57ad88e701d2a097a9f57187e5935b28a Mon Sep 17 00:00:00 2001 From: Jeong-Ryeol Date: Mon, 15 Sep 2025 15:18:33 +0900 Subject: [PATCH 1/9] =?UTF-8?q?[DOCS]=20=EC=BD=94=EB=93=9C=EB=A0=88?= =?UTF-8?q?=EB=B9=97=20pr=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A5=BC=20?= =?UTF-8?q?=EC=9C=84=ED=95=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/org/mybatis/jpetstore/domain/Account.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/org/mybatis/jpetstore/domain/Account.java b/src/main/java/org/mybatis/jpetstore/domain/Account.java index eb87ca17d..eec1909bc 100644 --- a/src/main/java/org/mybatis/jpetstore/domain/Account.java +++ b/src/main/java/org/mybatis/jpetstore/domain/Account.java @@ -23,6 +23,8 @@ * The Class Account. * * @author Eduardo Macarron + * # Add comment for test coderabbit + * 코드레빗 pr 테스트를 위해 추가되는 구문입니다. */ public class Account implements Serializable { From 670f65bb62b6a951c6ffa0952589341448f4bbde Mon Sep 17 00:00:00 2001 From: Jeong-Ryeol Date: Mon, 13 Oct 2025 17:26:54 +0900 Subject: [PATCH 2/9] =?UTF-8?q?=EB=AA=A8=EB=93=A0=20=EC=83=81=ED=92=88=20?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 모든 카테고리의 상품을 한 번에 볼 수 있는 기능 구현: - ItemMapper와 CatalogService에 getAllItems() 메서드 추가 - CatalogActionBean에 viewAllItems() 액션 추가 - AllItems.jsp 뷰 페이지 생성 - 사이드바와 퀵링크에 All 링크 추가 --- .../mybatis/jpetstore/mapper/ItemMapper.java | 2 + .../jpetstore/service/CatalogService.java | 4 ++ .../web/actions/CatalogActionBean.java | 11 ++++ .../mybatis/jpetstore/mapper/ItemMapper.xml | 21 ++++++ .../webapp/WEB-INF/jsp/catalog/AllItems.jsp | 65 +++++++++++++++++++ src/main/webapp/WEB-INF/jsp/catalog/Main.jsp | 6 ++ .../webapp/WEB-INF/jsp/common/IncludeTop.jsp | 3 + 7 files changed, 112 insertions(+) create mode 100644 src/main/webapp/WEB-INF/jsp/catalog/AllItems.jsp diff --git a/src/main/java/org/mybatis/jpetstore/mapper/ItemMapper.java b/src/main/java/org/mybatis/jpetstore/mapper/ItemMapper.java index a0c42d952..625dfde17 100644 --- a/src/main/java/org/mybatis/jpetstore/mapper/ItemMapper.java +++ b/src/main/java/org/mybatis/jpetstore/mapper/ItemMapper.java @@ -35,4 +35,6 @@ public interface ItemMapper { Item getItem(String itemId); + List getAllItems(); + } diff --git a/src/main/java/org/mybatis/jpetstore/service/CatalogService.java b/src/main/java/org/mybatis/jpetstore/service/CatalogService.java index 1af097edd..f41e71380 100644 --- a/src/main/java/org/mybatis/jpetstore/service/CatalogService.java +++ b/src/main/java/org/mybatis/jpetstore/service/CatalogService.java @@ -87,4 +87,8 @@ public Item getItem(String itemId) { public boolean isItemInStock(String itemId) { return itemMapper.getInventoryQuantity(itemId) > 0; } + + public List getAllItems() { + return itemMapper.getAllItems(); + } } diff --git a/src/main/java/org/mybatis/jpetstore/web/actions/CatalogActionBean.java b/src/main/java/org/mybatis/jpetstore/web/actions/CatalogActionBean.java index a688b3c6c..54aea14a6 100644 --- a/src/main/java/org/mybatis/jpetstore/web/actions/CatalogActionBean.java +++ b/src/main/java/org/mybatis/jpetstore/web/actions/CatalogActionBean.java @@ -42,6 +42,7 @@ public class CatalogActionBean extends AbstractActionBean { private static final String VIEW_PRODUCT = "/WEB-INF/jsp/catalog/Product.jsp"; private static final String VIEW_ITEM = "/WEB-INF/jsp/catalog/Item.jsp"; private static final String SEARCH_PRODUCTS = "/WEB-INF/jsp/catalog/SearchProducts.jsp"; + private static final String VIEW_ALL_ITEMS = "/WEB-INF/jsp/catalog/AllItems.jsp"; @SpringBean private transient CatalogService catalogService; @@ -197,6 +198,16 @@ public ForwardResolution searchProducts() { } } + /** + * View all items. + * + * @return the forward resolution + */ + public ForwardResolution viewAllItems() { + itemList = catalogService.getAllItems(); + return new ForwardResolution(VIEW_ALL_ITEMS); + } + /** * Clear. */ diff --git a/src/main/resources/org/mybatis/jpetstore/mapper/ItemMapper.xml b/src/main/resources/org/mybatis/jpetstore/mapper/ItemMapper.xml index 509f0d838..e5e5c7135 100644 --- a/src/main/resources/org/mybatis/jpetstore/mapper/ItemMapper.xml +++ b/src/main/resources/org/mybatis/jpetstore/mapper/ItemMapper.xml @@ -79,4 +79,25 @@ WHERE ITEMID = #{itemId} + + diff --git a/src/main/webapp/WEB-INF/jsp/catalog/AllItems.jsp b/src/main/webapp/WEB-INF/jsp/catalog/AllItems.jsp new file mode 100644 index 000000000..f3e331514 --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/catalog/AllItems.jsp @@ -0,0 +1,65 @@ +<%-- + + Copyright 2010-2025 the original author or 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. + +--%> +<%@ include file="../common/IncludeTop.jsp"%> + + + +
+ +

All Products

+ + + + + + + + + + + + + + + + + + +
Item IDProduct IDDescriptionList Price 
+ + ${item.itemId} + ${item.product.productId} + ${item.attribute1} ${item.attribute2} ${item.attribute3} + ${item.attribute4} ${item.attribute5} ${item.product.name} + + + + Add to Cart + +
+ +
+ +<%@ include file="../common/IncludeBottom.jsp"%> \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/jsp/catalog/Main.jsp b/src/main/webapp/WEB-INF/jsp/catalog/Main.jsp index 3e6e9aafc..a0ec1c141 100644 --- a/src/main/webapp/WEB-INF/jsp/catalog/Main.jsp +++ b/src/main/webapp/WEB-INF/jsp/catalog/Main.jsp @@ -29,6 +29,12 @@
+ + + + + + + + + diff --git a/src/main/webapp/WEB-INF/jsp/common/IncludeTop.jsp b/src/main/webapp/WEB-INF/jsp/common/IncludeTop.jsp index 979f21c8f..961c9ac24 100644 --- a/src/main/webapp/WEB-INF/jsp/common/IncludeTop.jsp +++ b/src/main/webapp/WEB-INF/jsp/common/IncludeTop.jsp @@ -33,7 +33,7 @@ JPetStore Demo - diff --git a/src/main/webapp/WEB-INF/web.xml b/src/main/webapp/WEB-INF/web.xml index a7f4dcd75..70c8c3a58 100644 --- a/src/main/webapp/WEB-INF/web.xml +++ b/src/main/webapp/WEB-INF/web.xml @@ -28,6 +28,23 @@ org.springframework.web.context.ContextLoaderListener + + + encodingFilter + org.springframework.web.filter.CharacterEncodingFilter + + encoding + UTF-8 + + + forceEncoding + true + + + + encodingFilter + /* + Stripes Filter StripesFilter From f60ec202a877c1be2d709bfa8d5a03927777752f Mon Sep 17 00:00:00 2001 From: Jeong-Ryeol Date: Wed, 29 Oct 2025 16:20:56 +0900 Subject: [PATCH 7/9] Add Ubuntu VM setup script --- scripts/setup-ubuntu-vm.sh | 105 +++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 scripts/setup-ubuntu-vm.sh diff --git a/scripts/setup-ubuntu-vm.sh b/scripts/setup-ubuntu-vm.sh new file mode 100644 index 000000000..f86e23443 --- /dev/null +++ b/scripts/setup-ubuntu-vm.sh @@ -0,0 +1,105 @@ +#!/bin/bash + +# Ubuntu VM에서 실행할 초기 설정 스크립트 +# 사용법: +# curl -s https://raw.githubusercontent.com/Jeong-Ryeol/jpetstore-6/master/scripts/setup-ubuntu-vm.sh | sudo bash + +set -e + +echo "=========================================" +echo "Ubuntu VM 초기 설정 시작" +echo "=========================================" + +# 시스템 업데이트 (간단하게) +echo "📦 패키지 목록 업데이트 중..." +apt-get update -qq + +# Java 17 설치 +echo "☕ Java 17 설치 중..." +apt-get install -y openjdk-17-jdk wget + +# Java 버전 확인 +java -version + +# Tomcat 사용자 생성 +echo "👤 Tomcat 사용자 생성 중..." +useradd -r -m -U -d /opt/tomcat -s /bin/false tomcat 2>/dev/null || echo "Tomcat user already exists" + +# Tomcat 디렉토리 생성 +mkdir -p /opt/tomcat + +# Tomcat 9 다운로드 +TOMCAT_VERSION="9.0.105" +echo "🐱 Tomcat ${TOMCAT_VERSION} 다운로드 중..." +cd /tmp +wget -q https://dlcdn.apache.org/tomcat/tomcat-9/v${TOMCAT_VERSION}/bin/apache-tomcat-${TOMCAT_VERSION}.tar.gz + +# Tomcat 압축 해제 +echo "📂 Tomcat 설치 중..." +tar xzf apache-tomcat-${TOMCAT_VERSION}.tar.gz -C /opt/tomcat --strip-components=1 + +# 권한 설정 +echo "🔐 권한 설정 중..." +chown -R tomcat:tomcat /opt/tomcat/ +chmod +x /opt/tomcat/bin/*.sh + +# Systemd 서비스 파일 생성 +echo "⚙️ Systemd 서비스 설정 중..." +tee /etc/systemd/system/tomcat.service > /dev/null </dev/null || true + +# 상태 확인 +sleep 5 +systemctl status tomcat --no-pager || true + +echo "=========================================" +echo "✅ Ubuntu VM 설정 완료!" +echo "=========================================" +echo "" +echo "다음 단계:" +echo "1. Oracle Cloud 콘솔에서 보안 목록 설정:" +echo " - Ingress Rules 추가: 0.0.0.0/0, TCP, 8080" +echo "" +echo "2. GitHub Secrets 업데이트:" +echo " - ORACLE_HOST: $(curl -s ifconfig.me)" +echo "" +echo "3. Tomcat 상태 확인:" +echo " sudo systemctl status tomcat" +echo "" +echo "4. 로그 확인:" +echo " sudo tail -f /opt/tomcat/logs/catalina.out" From 3cf1ff4ecbecbdc95bb6ed58e994969e39fc0d62 Mon Sep 17 00:00:00 2001 From: Jeong-Ryeol Date: Wed, 5 Nov 2025 15:22:19 +0900 Subject: [PATCH 8/9] =?UTF-8?q?AI=20=EC=B1=97=EB=B4=87=20=EB=B0=8F=20?= =?UTF-8?q?=EC=B6=94=EC=B2=9C=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AIRecommendationService에 실제 데이터베이스 제품 조회 기능 추가 * 같은 카테고리의 재고 있는 제품들을 조회하여 추천 * 아이템 ID를 클릭 가능한 링크로 변환 * 데모 모드도 실제 DB 제품 사용하도록 개선 - ChatbotService에 데이터베이스 기반 응답 기능 추가 * 모든 카테고리와 제품 정보를 DB에서 조회 * JPetStore가 살아있는 동물을 판매함을 명확히 설명 * AI 프롬프트 디버깅 로그 추가 - Item.jsp에서 추천 링크가 HTML로 렌더링되도록 수정 - 채팅 히스토리를 최근 20개로 제한하여 localStorage 오버플로우 방지 - VM 설정 스크립트에 저작권 헤더 추가 --- QUICK_START.md | 213 ++++++++++++++++++ scripts/setup-oracle-vm.sh | 16 ++ scripts/setup-ubuntu-vm.sh | 16 ++ .../service/AIRecommendationService.java | 157 +++++++++---- .../jpetstore/service/ChatbotService.java | 54 ++++- src/main/webapp/WEB-INF/jsp/catalog/Item.jsp | 2 +- .../WEB-INF/jsp/common/IncludeBottom.jsp | 8 +- 7 files changed, 420 insertions(+), 46 deletions(-) create mode 100644 QUICK_START.md diff --git a/QUICK_START.md b/QUICK_START.md new file mode 100644 index 000000000..05f714158 --- /dev/null +++ b/QUICK_START.md @@ -0,0 +1,213 @@ +# 🚀 Oracle Cloud 배포 빠른 시작 가이드 + +## ✅ 완료된 작업 + +1. ✅ GitHub 리포지토리 설정 완료 +2. ✅ GitHub Actions 워크플로우 생성 완료 +3. ✅ Oracle VM 설정 스크립트 생성 완료 +4. ✅ GEMINI_API_KEY 이미 GitHub Secrets에 추가됨 +5. ✅ 코드 푸시 완료 + +--- + +## 📝 남은 작업 (순서대로 진행) + +### 1단계: Oracle Cloud VM 생성 (약 10분) + +1. https://cloud.oracle.com 로그인 +2. **Compute** > **Instances** > **Create Instance** +3. 설정: + - Name: `jpetstore-server` + - Image: `Oracle Linux 8` + - Shape: `VM.Standard.E2.1.Micro` (Always Free) + - SSH keys: 새로 생성 또는 업로드 + - ⚠️ **중요**: Private Key 저장! +4. Create 클릭 +5. **Public IP** 복사 (예: `123.45.67.89`) + +--- + +### 2단계: 방화벽 설정 (약 3분) + +1. Oracle Cloud 콘솔: + - **Networking** > **Virtual Cloud Networks** + - VCN 선택 > **Security Lists** + - Default Security List 선택 + +2. **Add Ingress Rules**: + - Source CIDR: `0.0.0.0/0` + - IP Protocol: `TCP` + - Destination Port: `8080` + - Add 클릭 + +--- + +### 3단계: VM 초기 설정 (약 5분) + +**SSH 접속:** +```bash +ssh -i /path/to/your-key opc@ +``` + +**설정 스크립트 실행:** +```bash +# 스크립트 다운로드 +curl -o setup.sh https://raw.githubusercontent.com/Jeong-Ryeol/jpetstore-6/master/scripts/setup-oracle-vm.sh + +# 실행 +chmod +x setup.sh +sudo bash setup.sh +``` + +스크립트가 자동으로 설치: +- Java 17 +- Tomcat 9 +- 방화벽 설정 +- Systemd 서비스 + +--- + +### 4단계: GitHub Secrets 추가 (약 3분) + +https://github.com/Jeong-Ryeol/jpetstore-6/settings/secrets/actions + +**New repository secret** 클릭하여 추가: + +#### 1. ORACLE_HOST +- Value: VM의 Public IP (예: `123.45.67.89`) + +#### 2. ORACLE_USER +- Value: `opc` + +#### 3. ORACLE_SSH_KEY +```bash +# 로컬에서 SSH key 내용 복사 +cat ~/.ssh/oracle_key + +# 출력된 전체 내용을 GitHub Secret에 붙여넣기 +# -----BEGIN ... 부터 -----END ... 까지 전부 +``` + +#### 4. GEMINI_API_KEY +- ✅ 이미 추가되어 있음 + +--- + +### 5단계: 배포 실행 (자동) + +GitHub에서 자동 배포: + +**방법 1: 코드 푸시 (자동 트리거)** +```bash +git push origin master +``` + +**방법 2: 수동 실행** +1. https://github.com/Jeong-Ryeol/jpetstore-6/actions +2. **Deploy to Oracle Cloud** 선택 +3. **Run workflow** 클릭 + +--- + +### 6단계: 접속 확인 + +브라우저에서: +``` +http://:8080/jpetstore +``` + +예시: +``` +http://123.45.67.89:8080/jpetstore +``` + +--- + +## 🎯 필수 체크리스트 + +배포 전 확인: + +- [ ] Oracle Cloud VM 생성 완료 +- [ ] Public IP 확인 +- [ ] Security List에 8080 포트 추가 +- [ ] VM에서 설정 스크립트 실행 완료 +- [ ] `sudo systemctl status tomcat` 정상 작동 확인 +- [ ] GitHub Secrets 4개 모두 추가: + - [ ] GEMINI_API_KEY ✅ + - [ ] ORACLE_HOST + - [ ] ORACLE_USER + - [ ] ORACLE_SSH_KEY + +--- + +## 🔧 문제 해결 + +### 접속이 안 되는 경우 + +**1. 방화벽 확인 (VM 내부):** +```bash +sudo firewall-cmd --list-all +sudo firewall-cmd --permanent --add-port=8080/tcp +sudo firewall-cmd --reload +``` + +**2. Oracle Cloud 보안 규칙 확인:** +- Security List에 8080 Ingress Rule 있는지 확인 + +**3. Tomcat 상태:** +```bash +sudo systemctl status tomcat +sudo tail -100 /opt/tomcat/logs/catalina.out +``` + +### GitHub Actions 실패 + +**로그 확인:** +- https://github.com/Jeong-Ryeol/jpetstore-6/actions +- 실패한 워크플로우 클릭하여 에러 확인 + +**SSH 연결 실패:** +- ORACLE_SSH_KEY가 정확한지 확인 +- Private key 전체 내용이 포함되었는지 확인 + +--- + +## 📚 상세 문서 + +자세한 내용은 다음 문서를 참고하세요: +- [DEPLOYMENT_GUIDE.md](DEPLOYMENT_GUIDE.md) - 전체 배포 가이드 +- [AI_SETUP_GUIDE.txt](AI_SETUP_GUIDE.txt) - AI 기능 설정 +- [.github/workflows/deploy.yml](.github/workflows/deploy.yml) - 배포 워크플로우 + +--- + +## 📞 도움말 + +- **GitHub 리포지토리**: https://github.com/Jeong-Ryeol/jpetstore-6 +- **Oracle Cloud 문서**: https://docs.oracle.com/en-us/iaas/ +- **Tomcat 문서**: https://tomcat.apache.org/tomcat-9.0-doc/ + +--- + +## ⏱️ 예상 소요 시간 + +| 단계 | 소요 시간 | +|-----|----------| +| VM 생성 | 10분 | +| 방화벽 설정 | 3분 | +| VM 초기 설정 | 5분 | +| GitHub Secrets 추가 | 3분 | +| 배포 실행 | 3분 (자동) | +| **총합** | **약 25분** | + +--- + +## 🎉 성공! + +배포가 완료되면: +- ✅ 애플리케이션: http://:8080/jpetstore +- ✅ AI 챗봇 작동 (우측 하단 💬) +- ✅ AI 상품 추천 작동 (상품 상세 페이지) +- ✅ GitHub Actions 자동 배포 설정 완료 + +모든 코드 변경은 `git push`만으로 자동 배포됩니다! diff --git a/scripts/setup-oracle-vm.sh b/scripts/setup-oracle-vm.sh index 2425b81e8..ba285d44e 100644 --- a/scripts/setup-oracle-vm.sh +++ b/scripts/setup-oracle-vm.sh @@ -1,4 +1,20 @@ #!/bin/bash +# +# Copyright 2010-2025 the original author or 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. +# + # Oracle Cloud VM에서 실행할 초기 설정 스크립트 # 사용법: diff --git a/scripts/setup-ubuntu-vm.sh b/scripts/setup-ubuntu-vm.sh index f86e23443..d33681008 100644 --- a/scripts/setup-ubuntu-vm.sh +++ b/scripts/setup-ubuntu-vm.sh @@ -1,4 +1,20 @@ #!/bin/bash +# +# Copyright 2010-2025 the original author or 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. +# + # Ubuntu VM에서 실행할 초기 설정 스크립트 # 사용법: diff --git a/src/main/java/org/mybatis/jpetstore/service/AIRecommendationService.java b/src/main/java/org/mybatis/jpetstore/service/AIRecommendationService.java index d179ef4e5..a22b357d3 100644 --- a/src/main/java/org/mybatis/jpetstore/service/AIRecommendationService.java +++ b/src/main/java/org/mybatis/jpetstore/service/AIRecommendationService.java @@ -23,6 +23,7 @@ import java.util.List; import org.mybatis.jpetstore.domain.Item; +import org.mybatis.jpetstore.domain.Product; import org.springframework.stereotype.Service; /** @@ -38,11 +39,12 @@ public class AIRecommendationService { private final HttpClient httpClient; private final String apiKey; + private final CatalogService catalogService; - public AIRecommendationService() { + public AIRecommendationService(CatalogService catalogService) { this.httpClient = HttpClient.newHttpClient(); - // API 키를 환경 변수에서 읽기 this.apiKey = System.getenv(API_KEY_ENV); + this.catalogService = catalogService; } /** @@ -83,7 +85,7 @@ public List getRecommendations(Item item) { } /** - * Build a prompt for the AI based on item information. + * Build a prompt for the AI based on item information and available products from database. */ private String buildPrompt(Item item) { String productName = item.getProduct().getName(); @@ -92,12 +94,44 @@ private String buildPrompt(Item item) { String attributes = String.format("%s %s %s %s %s", nullSafe(item.getAttribute1()), nullSafe(item.getAttribute2()), nullSafe(item.getAttribute3()), nullSafe(item.getAttribute4()), nullSafe(item.getAttribute5())).trim(); - return String.format( - "당신은 애완동물 용품 쇼핑몰의 AI 추천 도우미입니다.\n\n" + "고객이 현재 다음 상품을 보고 있습니다:\n" + "- 상품명: %s\n" + "- 카테고리: %s\n" - + "- 설명: %s\n" + "- 특성: %s\n\n" + "이 고객에게 함께 구매하면 좋을 상품 3가지를 추천해주세요.\n" + "각 추천은 다음 형식으로 작성해주세요:\n" - + "1. [상품명]: [간단한 추천 이유 (한 문장)]\n" + "2. [상품명]: [간단한 추천 이유 (한 문장)]\n" + "3. [상품명]: [간단한 추천 이유 (한 문장)]\n\n" - + "실제 JPetStore에 있을 법한 상품을 추천하고, 친근하고 도움이 되는 톤으로 작성해주세요.", - productName, categoryId, description, attributes); + // 데이터베이스에서 같은 카테고리의 다른 제품들 조회 + List categoryProducts = catalogService.getProductListByCategory(categoryId); + StringBuilder availableItems = new StringBuilder(); + int itemCount = 0; + final int MAX_ITEMS = 15; // 프롬프트 길이 제한 + + for (Product product : categoryProducts) { + if (itemCount >= MAX_ITEMS) + break; + + // 현재 보고 있는 제품은 제외 + if (product.getProductId().equals(item.getProduct().getProductId())) { + continue; + } + + List items = catalogService.getItemListByProduct(product.getProductId()); + for (Item availableItem : items) { + if (itemCount >= MAX_ITEMS) + break; + + // 재고가 있는 상품만 추천 + if (catalogService.isItemInStock(availableItem.getItemId())) { + availableItems.append(String.format(" - [%s] %s (%s) - $%.2f\n", availableItem.getItemId(), + product.getName(), nullSafe(availableItem.getAttribute1()), availableItem.getListPrice())); + itemCount++; + } + } + } + + String availableItemsList = availableItems.length() > 0 ? availableItems.toString() + : " (현재 같은 카테고리에 추천 가능한 다른 상품이 없습니다)"; + + return String.format("당신은 JPetStore 애완동물 쇼핑몰의 AI 추천 도우미입니다.\n\n" + "고객이 현재 다음 상품을 보고 있습니다:\n" + "- 상품명: %s\n" + + "- 카테고리: %s\n" + "- 설명: %s\n" + "- 특성: %s\n\n" + "다음은 현재 JPetStore에서 판매 중인 같은 카테고리의 상품 목록입니다:\n%s\n" + + "위 목록에서 고객에게 함께 구매하면 좋을 상품 3가지를 추천해주세요.\n" + "각 추천은 다음 형식으로 작성해주세요 (반드시 [아이템ID]를 포함해야 합니다):\n" + + "1. [아이템ID] 상품명: 간단한 추천 이유 (한 문장)\n" + "2. [아이템ID] 상품명: 간단한 추천 이유 (한 문장)\n" + + "3. [아이템ID] 상품명: 간단한 추천 이유 (한 문장)\n\n" + "예시: 1. [EST-2] Small Angelfish: 대형 물고기와 함께 키우기 좋은 소형 어종입니다.\n\n" + + "친근하고 도움이 되는 톤으로 작성해주세요.", productName, categoryId, description, attributes, availableItemsList); } /** @@ -112,7 +146,7 @@ private String buildRequestBody(String prompt) { } /** - * Parse the Gemini API response and extract recommendations. + * Parse the Gemini API response and extract recommendations with item links. */ private List parseResponse(String responseBody) { List recommendations = new ArrayList<>(); @@ -135,8 +169,9 @@ private List parseResponse(String responseBody) { for (String line : lines) { line = line.trim(); if (!line.isEmpty() && (line.matches("^\\d+\\..*") || line.startsWith("-"))) { - // 숫자나 - 로 시작하는 줄만 추가 - recommendations.add(line); + // [아이템ID] 패턴을 찾아서 링크로 변환 + String processedLine = convertItemIdToLink(line); + recommendations.add(processedLine); } } } @@ -156,45 +191,91 @@ private List parseResponse(String responseBody) { } /** - * Provide demo recommendations when API is not available. + * Convert item IDs in square brackets to clickable links. Example: "[EST-1] Large Angelfish" -> + * "EST-1 Large Angelfish" + */ + private String convertItemIdToLink(String text) { + // [EST-1] 같은 패턴 찾기 + java.util.regex.Pattern pattern = java.util.regex.Pattern.compile("\\[([A-Z]+-\\d+)\\]"); + java.util.regex.Matcher matcher = pattern.matcher(text); + + if (matcher.find()) { + String itemId = matcher.group(1); + // [EST-1]을 링크로 변환 + String replacement = String.format("%s", + itemId, itemId); + return matcher.replaceFirst(replacement); + } + + return text; + } + + /** + * Provide demo recommendations when API is not available. Uses actual database products. */ private List getDemoRecommendations(Item item) { List recommendations = new ArrayList<>(); String categoryId = item.getProduct().getCategoryId(); + try { + // 데이터베이스에서 같은 카테고리의 다른 제품들 조회 + List categoryProducts = catalogService.getProductListByCategory(categoryId); + int count = 0; + + for (Product product : categoryProducts) { + if (count >= 3) + break; + + // 현재 보고 있는 제품은 제외 + if (product.getProductId().equals(item.getProduct().getProductId())) { + continue; + } + + List items = catalogService.getItemListByProduct(product.getProductId()); + if (!items.isEmpty()) { + Item firstItem = items.get(0); + // 재고가 있는 상품만 추천 + if (catalogService.isItemInStock(firstItem.getItemId())) { + String recommendation = String.format( + "%d. %s %s: %s", count + 1, + firstItem.getItemId(), firstItem.getItemId(), product.getName(), getDemoReasonByCategory(categoryId)); + recommendations.add(recommendation); + count++; + } + } + } + + // 추천할 제품이 없으면 일반 메시지 + if (recommendations.isEmpty()) { + recommendations.add("현재 같은 카테고리에 추천할 다른 제품이 없습니다."); + } + + } catch (Exception e) { + System.err.println("Error getting demo recommendations: " + e.getMessage()); + recommendations.add("추천 상품을 불러오는 중 문제가 발생했습니다."); + } + + return recommendations; + } + + /** + * Get generic recommendation reason by category. + */ + private String getDemoReasonByCategory(String categoryId) { switch (categoryId.toUpperCase()) { case "FISH": - recommendations.add("1. 고급 어류 사료: 물고기의 건강한 성장과 선명한 색상 유지에 필수적입니다."); - recommendations.add("2. 수족관 여과 시스템: 깨끗한 수질 관리로 물고기의 건강을 지켜줍니다."); - recommendations.add("3. 수초 세트: 자연스러운 서식 환경을 제공하여 물고기의 스트레스를 줄여줍니다."); - break; + return "함께 키우기 좋은 어종입니다"; case "DOGS": - recommendations.add("1. 프리미엄 강아지 사료: 균형 잡힌 영양으로 반려견의 건강을 책임집니다."); - recommendations.add("2. 강아지 장난감: 혼자 있는 시간에도 즐겁게 놀 수 있도록 도와줍니다."); - recommendations.add("3. 산책용 목줄과 하네스: 안전하고 편안한 산책을 위한 필수 용품입니다."); - break; + return "반려견에게 필요한 제품입니다"; case "CATS": - recommendations.add("1. 고양이 캣타워: 고양이의 본능적인 등반 욕구를 충족시켜줍니다."); - recommendations.add("2. 고양이 모래: 청결한 화장실 환경 유지로 스트레스를 줄여줍니다."); - recommendations.add("3. 고양이 간식: 사랑하는 반려묘와의 교감을 깊게 해줍니다."); - break; + return "고양이가 좋아할 제품입니다"; case "BIRDS": - recommendations.add("1. 새 모이: 다양한 영양소가 풍부한 고품질 모이로 건강을 지켜줍니다."); - recommendations.add("2. 새장 장식품: 새가 즐겁게 놀 수 있는 환경을 만들어줍니다."); - recommendations.add("3. 새 목욕 용품: 깃털 관리와 위생에 필수적인 아이템입니다."); - break; + return "새의 건강에 도움이 됩니다"; case "REPTILES": - recommendations.add("1. 파충류 사료: 종에 맞는 영양 공급으로 건강을 유지합니다."); - recommendations.add("2. 온도 조절 램프: 파충류에게 필수적인 적정 온도를 제공합니다."); - recommendations.add("3. 바닥재: 자연 서식지와 유사한 환경을 조성해줍니다."); - break; + return "파충류 사육에 유용합니다"; default: - recommendations.add("1. 애완동물 종합 영양제: 모든 반려동물의 건강을 지원합니다."); - recommendations.add("2. 애완동물 위생 용품: 청결한 환경 유지로 질병을 예방합니다."); - recommendations.add("3. 애완동물 장난감: 재미있는 놀이로 스트레스를 해소해줍니다."); + return "함께 구매하면 좋은 제품입니다"; } - - return recommendations; } /** diff --git a/src/main/java/org/mybatis/jpetstore/service/ChatbotService.java b/src/main/java/org/mybatis/jpetstore/service/ChatbotService.java index 771dba219..da0bf9066 100644 --- a/src/main/java/org/mybatis/jpetstore/service/ChatbotService.java +++ b/src/main/java/org/mybatis/jpetstore/service/ChatbotService.java @@ -19,7 +19,11 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.util.List; +import org.mybatis.jpetstore.domain.Category; +import org.mybatis.jpetstore.domain.Item; +import org.mybatis.jpetstore.domain.Product; import org.springframework.stereotype.Service; /** @@ -35,10 +39,12 @@ public class ChatbotService { private final HttpClient httpClient; private final String apiKey; + private final CatalogService catalogService; - public ChatbotService() { + public ChatbotService(CatalogService catalogService) { this.httpClient = HttpClient.newHttpClient(); this.apiKey = System.getenv(API_KEY_ENV); + this.catalogService = catalogService; } /** @@ -79,14 +85,50 @@ public String getChatResponse(String userMessage) { } /** - * Build a chat prompt for the AI. + * Build a chat prompt for the AI with actual database product information. */ private String buildChatPrompt(String userMessage) { + // 실제 데이터베이스의 카테고리와 제품 정보 조회 + StringBuilder productInfo = new StringBuilder(); + try { + List categories = catalogService.getCategoryList(); + productInfo.append("현재 JPetStore에서 판매 중인 실제 애완동물 목록:\n\n"); + + for (Category category : categories) { + List products = catalogService.getProductListByCategory(category.getCategoryId()); + if (!products.isEmpty()) { + productInfo.append(String.format("【%s 카테고리】\n", category.getName())); + int count = 0; + for (Product product : products) { + if (count >= 3) + break; // 카테고리당 3개만 표시 + List items = catalogService.getItemListByProduct(product.getProductId()); + if (!items.isEmpty() && catalogService.isItemInStock(items.get(0).getItemId())) { + productInfo.append(String.format(" - %s: %s (가격: $%.2f~)\n", product.getName(), product.getDescription(), + items.get(0).getListPrice())); + count++; + } + } + productInfo.append("\n"); + } + } + + // 디버깅용 로그 + System.out.println("[CHATBOT] Product info for AI:\n" + productInfo.toString()); + + } catch (Exception e) { + System.err.println("Error fetching product info for chatbot: " + e.getMessage()); + e.printStackTrace(); + productInfo.append("(제품 정보를 불러오는 중 오류가 발생했습니다)\n"); + } + return String.format( - "당신은 JPetStore 애완동물 쇼핑몰의 친절한 AI 고객 지원 도우미입니다.\n\n" + "다음 질문에 친절하고 도움이 되는 답변을 제공해주세요:\n\n" + "고객 질문: %s\n\n" - + "답변 시 다음을 고려해주세요:\n" + "1. 애완동물 용품에 대한 전문적인 조언\n" + "2. JPetStore에서 판매하는 상품 (물고기, 강아지, 고양이, 새, 파충류 용품)\n" - + "3. 간단명료하고 친근한 톤\n" + "4. 필요시 구체적인 상품 추천\n\n" + "3-4문장 이내로 답변해주세요.", - userMessage); + "당신은 JPetStore 애완동물 판매 쇼핑몰의 친절한 AI 고객 지원 도우미입니다.\n\n" + + "**중요**: JPetStore는 살아있는 애완동물(물고기, 강아지, 고양이, 새, 파충류)을 직접 판매하는 펫샵입니다.\n\n" + "%s\n" + "고객 질문: %s\n\n" + + "답변 시 다음을 고려해주세요:\n" + "1. 위에 나열된 것은 실제 살아있는 애완동물들입니다 (용품이 아님)\n" + + "2. 고객이 동물을 사고 싶다고 하면, 해당 동물의 특징과 가격을 구체적으로 안내\n" + "3. 간단명료하고 친근한 톤\n" + + "4. 예: '고양이를 사고 싶으시군요! Manx($10.50)는 쥐 잡기에 탁월하고, Persian($12.50)은 온순한 성격입니다'\n\n" + "3-4문장 이내로 답변해주세요.", + productInfo.toString(), userMessage); } /** diff --git a/src/main/webapp/WEB-INF/jsp/catalog/Item.jsp b/src/main/webapp/WEB-INF/jsp/catalog/Item.jsp index adde407cd..639036d4b 100644 --- a/src/main/webapp/WEB-INF/jsp/catalog/Item.jsp +++ b/src/main/webapp/WEB-INF/jsp/catalog/Item.jsp @@ -77,7 +77,7 @@
  • - ${recommendation} +
diff --git a/src/main/webapp/WEB-INF/jsp/common/IncludeBottom.jsp b/src/main/webapp/WEB-INF/jsp/common/IncludeBottom.jsp index 9db17cf3e..91ac25959 100644 --- a/src/main/webapp/WEB-INF/jsp/common/IncludeBottom.jsp +++ b/src/main/webapp/WEB-INF/jsp/common/IncludeBottom.jsp @@ -271,7 +271,7 @@ } } - // Save chat history to localStorage + // Save chat history to localStorage (최근 20개만 유지) function saveChatHistory() { var messages = []; var messageElements = chatbotMessages.querySelectorAll('.chatbot-message'); @@ -280,6 +280,12 @@ var text = elem.querySelector('.chatbot-message-content').textContent; messages.push({ type: type, text: text }); }); + + // 최근 20개 메시지만 유지 (너무 많은 히스토리가 쌓이지 않도록) + if (messages.length > 20) { + messages = messages.slice(messages.length - 20); + } + localStorage.setItem('jpetstore_chat_history', JSON.stringify(messages)); } From b79dfd765ecccf57952a7e07fbf2a8c4fe312474 Mon Sep 17 00:00:00 2001 From: Jongchanpark22 <22channy@naver.com> Date: Fri, 21 Nov 2025 00:25:31 +0900 Subject: [PATCH 9/9] =?UTF-8?q?=EA=B2=8C=EC=9E=84=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pom.xml | 17 + .../domain/gamesimulation/GameOption.java | 22 + .../domain/gamesimulation/GameSession.java | 51 +++ .../domain/gamesimulation/GameState.java | 38 ++ .../domain/gamesimulation/GameStateView.java | 45 ++ .../jpetstore/mapper/GameSessionMapper.java | 12 + .../jpetstore/service/GamePromptBuilder.java | 392 ++++++++++++++++++ .../service/GameSessionRepository.java | 34 ++ .../service/GameSimulationService.java | 153 +++++++ .../jpetstore/service/GeminiClient.java | 176 ++++++++ .../web/actions/GameSimulationActionBean.java | 117 ++++++ .../database/jpetstore-hsqldb-schema.sql | 16 + .../jpetstore/mapper/GameSessionMapper.xml | 81 ++++ .../webapp/WEB-INF/applicationContext.xml | 10 + .../WEB-INF/jsp/account/EditAccountForm.jsp | 4 + .../jsp/account/IncludeAccountFields.jsp | 4 + .../WEB-INF/jsp/account/NewAccountForm.jsp | 4 + .../webapp/WEB-INF/jsp/account/SignonForm.jsp | 4 + src/main/webapp/WEB-INF/jsp/cart/Cart.jsp | 4 + src/main/webapp/WEB-INF/jsp/cart/Checkout.jsp | 4 + .../webapp/WEB-INF/jsp/cart/IncludeMyList.jsp | 4 + .../webapp/WEB-INF/jsp/catalog/AllItems.jsp | 4 + .../webapp/WEB-INF/jsp/catalog/Category.jsp | 4 + src/main/webapp/WEB-INF/jsp/catalog/Item.jsp | 2 +- src/main/webapp/WEB-INF/jsp/catalog/Main.jsp | 149 ++++++- .../webapp/WEB-INF/jsp/catalog/Product.jsp | 4 + .../WEB-INF/jsp/catalog/SearchProducts.jsp | 4 + src/main/webapp/WEB-INF/jsp/common/Error.jsp | 4 + .../WEB-INF/jsp/common/IncludeBottom.jsp | 20 +- .../webapp/WEB-INF/jsp/common/IncludeTop.jsp | 2 +- .../webapp/WEB-INF/jsp/order/ConfirmOrder.jsp | 4 + .../webapp/WEB-INF/jsp/order/ListOrders.jsp | 4 + .../webapp/WEB-INF/jsp/order/NewOrderForm.jsp | 4 + .../webapp/WEB-INF/jsp/order/ShippingForm.jsp | 4 + .../webapp/WEB-INF/jsp/order/ViewOrder.jsp | 4 + 35 files changed, 1371 insertions(+), 34 deletions(-) create mode 100644 src/main/java/org/mybatis/jpetstore/domain/gamesimulation/GameOption.java create mode 100644 src/main/java/org/mybatis/jpetstore/domain/gamesimulation/GameSession.java create mode 100644 src/main/java/org/mybatis/jpetstore/domain/gamesimulation/GameState.java create mode 100644 src/main/java/org/mybatis/jpetstore/domain/gamesimulation/GameStateView.java create mode 100644 src/main/java/org/mybatis/jpetstore/mapper/GameSessionMapper.java create mode 100644 src/main/java/org/mybatis/jpetstore/service/GamePromptBuilder.java create mode 100644 src/main/java/org/mybatis/jpetstore/service/GameSessionRepository.java create mode 100644 src/main/java/org/mybatis/jpetstore/service/GameSimulationService.java create mode 100644 src/main/java/org/mybatis/jpetstore/service/GeminiClient.java create mode 100644 src/main/java/org/mybatis/jpetstore/web/actions/GameSimulationActionBean.java create mode 100644 src/main/resources/org/mybatis/jpetstore/mapper/GameSessionMapper.xml diff --git a/pom.xml b/pom.xml index 0d531dedd..9f473ce82 100644 --- a/pom.xml +++ b/pom.xml @@ -227,6 +227,23 @@ ${spring.version} test + + + com.fasterxml.jackson.core + jackson-databind + 2.16.1 + + + com.fasterxml.jackson.core + jackson-core + 2.16.1 + + + com.fasterxml.jackson.core + jackson-annotations + 2.16.1 + + diff --git a/src/main/java/org/mybatis/jpetstore/domain/gamesimulation/GameOption.java b/src/main/java/org/mybatis/jpetstore/domain/gamesimulation/GameOption.java new file mode 100644 index 000000000..48696beac --- /dev/null +++ b/src/main/java/org/mybatis/jpetstore/domain/gamesimulation/GameOption.java @@ -0,0 +1,22 @@ +package org.mybatis.jpetstore.domain.gamesimulation; + +public class GameOption { + private String id; // "A", "B", ... + private String text; // 버튼에 보여줄 문구 + + public GameOption() {} + + public GameOption(String id, String text) { + this.id = id; + this.text = text; + } + + //getter + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public String getText() { return text; } + public void setText(String text) { this.text = text; } + +} + diff --git a/src/main/java/org/mybatis/jpetstore/domain/gamesimulation/GameSession.java b/src/main/java/org/mybatis/jpetstore/domain/gamesimulation/GameSession.java new file mode 100644 index 000000000..ec6e3563d --- /dev/null +++ b/src/main/java/org/mybatis/jpetstore/domain/gamesimulation/GameSession.java @@ -0,0 +1,51 @@ +package org.mybatis.jpetstore.domain.gamesimulation; + +import java.io.Serializable; + +public class GameSession implements Serializable { + + private String sessionId; + private String accountId; + private String breedId; + private int timeHour; + private int health; + private int happiness; + private int cost; + private boolean finished; + private String lastMessage; + private String lastOptionJson; + private int finalScore; + + public String getSessionId() { return sessionId; } + public void setSessionId(String sessionId) { this.sessionId = sessionId; } + + public String getAccountId() { return accountId; } + public void setAccountId(String accountId) { this.accountId = accountId; } + + public String getBreedId() { return breedId; } + public void setBreedId(String breedId) { this.breedId = breedId; } + + public int getTimeHour() { return timeHour; } + public void setTimeHour(int timeHour) { this.timeHour = timeHour; } + + public int getHealth() { return health; } + public void setHealth(int health) { this.health = health; } + + public int getHappiness() { return happiness; } + public void setHappiness(int happiness) { this.happiness = happiness; } + + public int getCost() { return cost; } + public void setCost(int cost) { this.cost = cost; } + + public boolean isFinished() { return finished; } + public void setFinished(boolean finished) { this.finished = finished; } + + public String getLastMessage() { return lastMessage; } + public void setLastMessage(String lastMessage) { this.lastMessage = lastMessage; } + + public String getLastOptionJson() { return lastOptionJson; } + public void setLastOptionJson(String lastOptionJson) { this.lastOptionJson = lastOptionJson; } + + public int getFinalScore() { return finalScore; } + public void setFinalScore(int finalScore) { this.finalScore = finalScore; } +} diff --git a/src/main/java/org/mybatis/jpetstore/domain/gamesimulation/GameState.java b/src/main/java/org/mybatis/jpetstore/domain/gamesimulation/GameState.java new file mode 100644 index 000000000..554371a56 --- /dev/null +++ b/src/main/java/org/mybatis/jpetstore/domain/gamesimulation/GameState.java @@ -0,0 +1,38 @@ +package org.mybatis.jpetstore.domain.gamesimulation; + +import java.util.List; + +public class GameState { + private int timeHour; // 7, 8, 9 ... 31 + private int health; // 0 ~ 100+ + private int happiness; // 0 ~ 100+ + private int cost; // 누적 비용(원) + private String message; // “고양이가 배고파합니다…” + private List options; + private boolean finished; // 24시간 끝났는지 + + public GameState() {} + + public GameState(int timeHour, int health, int happiness, int cost, String message) { + this.timeHour = timeHour; + this.health = health; + this.happiness = happiness; + this.cost = cost; + this.message = message; + } + + public int getTimeHour() {return timeHour;} + public int getHealth() {return health;} + public int getHappiness() {return happiness;} + public int getCost() {return cost;} + public String getMessage() {return message;} + public List getOptions() {return options;} + public boolean isFinished() {return finished;} + public void setTimeHour(int timeHour) {this.timeHour = timeHour;} + public void setHealth(int health) {this.health = health;} + public void setHappiness(int happiness) {this.happiness = happiness;} + public void setCost(int cost) {this.cost = cost;} + public void setMessage(String message) {this.message = message;} + public void setOptions(List options) {this.options = options;} + public void setFinished(boolean finished) {this.finished = finished;} +} diff --git a/src/main/java/org/mybatis/jpetstore/domain/gamesimulation/GameStateView.java b/src/main/java/org/mybatis/jpetstore/domain/gamesimulation/GameStateView.java new file mode 100644 index 000000000..2782648e7 --- /dev/null +++ b/src/main/java/org/mybatis/jpetstore/domain/gamesimulation/GameStateView.java @@ -0,0 +1,45 @@ +package org.mybatis.jpetstore.domain.gamesimulation; + +import java.io.Serializable; +import java.util.List; + +public class GameStateView implements Serializable { + + private String sessionId; // 이걸로 다음 스텝 요청 + private int timeHour; // 7, 8, 9... + private int health; + private int happiness; + private int cost; + private boolean finished; + private String message; // “고양이가 배고파합니다…” + private List options; + private int finalScore; + + + public String getSessionId() { return sessionId; } + public void setSessionId(String sessionId) { this.sessionId = sessionId; } + + public int getTimeHour() { return timeHour; } + public void setTimeHour(int timeHour) { this.timeHour = timeHour; } + + public int getHealth() { return health; } + public void setHealth(int health) { this.health = health; } + + public int getHappiness() { return happiness; } + public void setHappiness(int happiness) { this.happiness = happiness; } + + public int getCost() { return cost; } + public void setCost(int cost) { this.cost = cost; } + + public boolean isFinished() { return finished; } + public void setFinished(boolean finished) { this.finished = finished; } + + public String getMessage() { return message; } + public void setMessage(String message) { this.message = message; } + + public List getOptions() { return options; } + public void setOptions(List options) { this.options = options; } + + public int getFinalScore() { return finalScore; } + public void setFinalScore(int finalScore) { this.finalScore = finalScore; } +} diff --git a/src/main/java/org/mybatis/jpetstore/mapper/GameSessionMapper.java b/src/main/java/org/mybatis/jpetstore/mapper/GameSessionMapper.java new file mode 100644 index 000000000..0d73c4ee8 --- /dev/null +++ b/src/main/java/org/mybatis/jpetstore/mapper/GameSessionMapper.java @@ -0,0 +1,12 @@ +package org.mybatis.jpetstore.mapper; + +import org.mybatis.jpetstore.domain.gamesimulation.GameSession; + +public interface GameSessionMapper { + + void insertGameSession(GameSession session); + + void updateGameSession(GameSession session); + + GameSession getGameSession(String sessionId); +} diff --git a/src/main/java/org/mybatis/jpetstore/service/GamePromptBuilder.java b/src/main/java/org/mybatis/jpetstore/service/GamePromptBuilder.java new file mode 100644 index 000000000..4f6ea2066 --- /dev/null +++ b/src/main/java/org/mybatis/jpetstore/service/GamePromptBuilder.java @@ -0,0 +1,392 @@ +package org.mybatis.jpetstore.service; + +import org.mybatis.jpetstore.domain.gamesimulation.GameSession; +import org.mybatis.jpetstore.domain.gamesimulation.GameStateView; +import org.mybatis.jpetstore.domain.gamesimulation.GameOption; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * Gemini에게 보낼 프롬프트를 만들어주는 빌더. + * - 게임 규칙 설명 + * - JSON 형식 강제 + * - 직전 상태/선택 정보 포함 + */ +@Component +public class GamePromptBuilder { + + /** + * 게임 처음 시작할 때 사용하는 프롬프트. + * + * @param accountId 유저 ID (필요 없으면 프롬프트에서 안 써도 됨) + * @param breedName 시뮬레이션할 반려동물 품종 이름 (예: Persian, Bulldog 등) + */ + public String buildStartPrompt(String accountId, String breedName) { + return """ + 당신은 반려동물 돌봄 시뮬레이션 게임 엔진입니다. + 사용자는 24시간 동안 %s를 돌보는 게임을 진행합니다. + + ### 공통 게임 규칙 + - timeHour는 아침 7시에서 시작합니다. (예: 7, 8, 9 ...) + - 다음날 아침 7시(=31) 이상이 되면 finished=true로 설정하고 시뮬레이션을 종료합니다. + - health, happiness, cost는 사용자의 선택에 따라 현실적으로 변화해야 합니다. + - health : 0 ~ 150 범위 + - happiness : 0 ~ 150 범위 + - cost : 한국 원화 기준 누적 비용 (정수) + + ### 동물별 특별 규칙 (반드시 계속 유지할 것) + + - 이번 시뮬레이션의 대상 동물: "%s" + + - 강아지(대형견·소형견 포함): + - 산책이 중요하며, 산책 중에 배변을 해결합니다. + - 산책이 부족하거나 배변을 오래 참으면 health/happiness가 감소합니다. + - 고양이: + - 화장실 모래에서만 배변합니다. + - 화장실 청결이 중요하며, 청소를 소홀하면 health/ happiness가 감소합니다. + - 환경 변화와 낯선 사람에 예민합니다. + - 새: + - 새장 내 환경(물, 모이, 횃대, 목욕, 햇볕) 관리가 중요합니다. + - 청결, 목욕을 소홀히 하면 health/happiness가 감소합니다. + + ### 선택지(옵션) 설계 규칙 (자연스럽고 현실적인 선택 분포) + - 각 턴의 옵션(options[])은 '누가 봐도 티 나는 극단적인 선택지'가 아니라 + **현실에서 반려동물을 키우다 보면 흔히 발생하는 자연스러운 선택들로 구성합니다.** + - 선택지는 아래 세 유형 중 최소 두 가지 이상을 섞어 구성합니다. + 1) **적극적이고 이상적인 선택지** + - 예: 산책을 나간다, 장난감으로 놀아준다, 사료를 급여한다 등 + - health/happiness가 자연스럽게 증가할 수 있음 + 2) **겉보기엔 괜찮아 보이지만 완벽하진 않은 선택지** (이 부분이 핵심!) + - 사용자가 의도적으로 나쁘려는 게 아니라 + **‘현실에서 바빠서/피곤해서 종종 하게 되는 선택’** + - 예: + - “지금 일 때문에 바빠서 잠깐만 쓰다듬고 넘어간다” + - “밖에 나갈 준비가 귀찮아 짧은 산책만 하고 돌아온다” + - “게임 중이라 반응은 해주지만 제대로 신경 쓰지는 못한다” + - “사료는 줬지만 물은 깜박했다” + - health/happiness 변화는 작거나, 한쪽이 오르고 다른 한쪽이 미세하게 떨어지는 등 + **미묘한 결과**를 만들어야 합니다. + 3) **눈에 띄지 않지만 실제로는 좋지 않은 선택지** + - 지나치게 부정적인 표현을 사용하지 말고, + **실수·방심·게으름 같은 인간적인 상황**으로 표현합니다. + - 예: + - “피곤해서 산책을 조금 미룬다” + - “핸드폰을 보다가 강아지가 부르는 걸 바로 알아채지 못한다” + - “화장실 청소를 ‘조금만 있다가’ 하려다 까먹는다” + - health/happiness는 확실히 감소하지만, + **표현 자체는 부드럽고 자연스럽게** 적습니다. + - 옵션 세 가지 모두 + “누가 봐도 정답/오답처럼 보이도록 만들지 말고” + **모두 현실적인 인간적 상황처럼 보이도록 구성해야 합니다.** + - health/happiness는 선택지 난이도에 따라 다음 범위에서 변동합니다: + - 이상적인 선택: +4 ~ +12 + - 애매한 선택: +1 ~ -4 + - 인간적 실수/게으름 선택: -6 ~ -15 + (하지만 표현은 부드럽고 자연스럽게) + + ### 숫자 표기 규칙 (매우 중요) + - timeHour, health, happiness, cost, finished 값은 반드시 **단일 리터럴 값**으로만 작성하세요. + - 예: 7, 100, 50, 0, false + - 절대 이렇게 쓰면 안 됩니다: + - 7+3 + - 1,000 + - "3000원" + - "50" + - 2900 + 80000 + - cost에는 "원" 같은 단위를 붙이지 말고, 정수 숫자만 사용하세요. + + ### 응답 JSON 형식 (필수) + 아래 형식과 동일한 key만 사용해야 합니다. + JSON 바깥에 어떤 설명도 쓰지 마세요. + ```json 같은 코드 블록 표시도 절대 사용하지 마세요. + + { + "message": "상황 설명 (자연스러운 한국어 한 문단)", + "timeHour": 7, + "health": 100, + "happiness": 50, + "cost": 0, + "finished": false, + "finalScore": 0, + "options": [ + { "id": "A", "text": "선택지1" }, + { "id": "B", "text": "선택지2" }, + { "id": "C", "text": "선택지3" } + ] + } + + ### 지금 할 일 + - timeHour는 **반드시 7**로 설정합니다. + - 기본 health=100, happiness=50, cost=0, finished=false로 설정합니다. + - options는 2~3개 정도 제시합니다. + - "%s"를(을) 처음 집에 데려온 아침 상황을 이야기 형식으로 묘사하세요. + - 각 동물의 대표적인 습성/관리 포인트(산책, 배변, 화장실, 새장 관리 등)를 message와 options.text에 자연스럽게 반영하세요. + - 아직 게임 시작 턴이므로 finished는 **반드시 false**로 유지합니다. + - 응답은 반드시 위 JSON 형식으로만 반환하세요. + """.formatted(breedName, breedName,breedName); + } + + + + /** + * 다음 스텝으로 넘어갈 때 사용하는 프롬프트. + * + * @param session DB에 저장된 세션 정보 + * @param lastView 우리가 가진 마지막 상태 (프론트에 보냈던 값 기준) + * @param chosenOptionId 사용자가 고른 옵션 ID (예: "A", "B", "C") + */ + public String buildNextPrompt(GameSession session, + GameStateView lastView, + String chosenOptionId) { + // 응답안넘어가서 응답 저장됬는지 확인하는 디버깅 용입니다. + System.out.println("=== [buildNextPrompt] 디버그 ==="); + System.out.println("chosenOptionId = " + chosenOptionId); + + if (lastView == null) { + System.out.println("lastView == null"); + } else { + System.out.println("lastView.timeHour = " + lastView.getTimeHour()); + System.out.println("lastView.message = " + lastView.getMessage()); + + if (lastView.getOptions() == null) { + System.out.println("lastView.options == null"); + } else { + System.out.println("lastView.options.size = " + lastView.getOptions().size()); + for (GameOption opt : lastView.getOptions()) { + System.out.println(" opt.id = " + opt.getId() + ", opt.text = " + opt.getText()); + } + } + } + String breedName = session.getBreedId(); + + // 직전 턴 상태/옵션 JSON + String lastStateJson = toStateJsonForPrompt(lastView); + String lastOptionsJson = toOptionsJsonForPrompt( + lastView != null ? lastView.getOptions() : null + ); + + // 선택한 옵션의 text 추출 (없으면 빈 문자열) + String chosenOptionText = ""; + if (lastView != null && lastView.getOptions() != null) { + for (GameOption opt : lastView.getOptions()) { + if (chosenOptionId != null && chosenOptionId.equals(opt.getId())) { + chosenOptionText = opt.getText(); + break; + } + } + } + + int prevTimeHour = (lastView != null) ? lastView.getTimeHour() : session.getTimeHour(); + + String prompt = """ + 당신은 반려동물 돌봄 시뮬레이션 게임 엔진입니다. + 사용자는 24시간 동안 동물을 돌보는 게임을 진행합니다. + + ### 공통 게임 규칙 + - 다음날 아침 7시(timeHour = 31) 이상이 되면 finished=true로 설정하고 시뮬레이션을 종료합니다. + - health, happiness, cost는 사용자의 선택에 따라 현실적으로 변화해야 합니다. + - health : 0 ~ 150 범위 + - happiness : 0 ~ 150 범위 + - cost : 한국 원화 기준 누적 비용 (정수) + + ### 동물별 특별 규칙 (반드시 계속 유지할 것) + - 이번 시뮬레이션의 대상 동물: "%s" + - 강아지(대형견·소형견 포함): + - 산책이 중요하며, 산책 중에 배변을 해결합니다. + - 산책이 부족하거나 배변을 오래 참으면 health/happiness가 감소합니다. + - 고양이: + - 화장실 모래에서만 배변합니다. + - 화장실 청결이 중요하며, 청소를 소홀하면 health/ happiness가 감소합니다. + - 환경 변화와 낯선 사람에 예민합니다. + - 새: + - 새장 내 환경(물, 모이, 횃대, 목욕, 햇볕) 관리가 중요합니다. + - 청결, 목욕을 소홀히 하면 health/happiness가 감소합니다. + + ### 1. 직전 턴 정보 + 아래 JSON은 직전 턴에서 사용자에게 보여줬던 상태/선택지입니다. + + lastState: + %s + + lastOptions: + %s + + - 직전 턴의 timeHour 값: %d + - 사용자가 직전 턴에서 실제로 선택한 옵션: + - id: "%s" + - text: "%s" + + ### 2. 선택 반영 규칙 (가장 중요한 규칙) + 1. 반드시 위에서 명시한 선택 **id="%s"**, **text="%s"** 이 실제로 실행된 뒤의 결과만을 계산해야 합니다. + 2. 이번 턴의 message에는 이 선택 하나의 결과만을 서술해야 합니다. + - 사용자가 선택하지 않은 다른 행동(예: 산책, 목욕, 다른 메뉴)은 message에 등장시키지 마세요. + 3. health, happiness, cost 변화는 이 선택의 효과를 현실적으로 반영해야 합니다. + + ### 선택지(옵션) 설계 규칙 (자연스럽고 현실적인 선택 분포) + - 각 턴의 옵션(options[])은 '누가 봐도 티 나는 극단적인 선택지'가 아니라 + **현실에서 반려동물을 키우다 보면 흔히 발생하는 자연스러운 선택들로 구성합니다.** + - 선택지는 아래 세 유형 중 최소 두 가지 이상을 섞어 구성합니다. + 1) **적극적이고 이상적인 선택지** + - 예: 산책을 나간다, 장난감으로 놀아준다, 사료를 급여한다 등 + - health/happiness가 자연스럽게 증가할 수 있음 + 2) **겉보기엔 괜찮아 보이지만 완벽하진 않은 선택지** (이 부분이 핵심!) + - 사용자가 의도적으로 나쁘려는 게 아니라 **‘현실에서 바빠서/피곤해서 종종 하게 되는 선택’** + - 예: + - “지금 일 때문에 바빠서 잠깐만 쓰다듬고 넘어간다” + - “밖에 나갈 준비가 귀찮아 짧은 산책만 하고 돌아온다” + - “게임 중이라 반응은 해주지만 제대로 신경 쓰지는 못한다” + - “사료는 줬지만 물은 깜박했다” + - health/happiness 변화는 작거나, 한쪽이 오르고 다른 한쪽이 미세하게 떨어지는 등 + **미묘한 결과**를 만들어야 합니다. + 3) **눈에 띄지 않지만 실제로는 좋지 않은 선택지** + - 지나치게 부정적인 표현을 사용하지 말고, + **실수·방심·게으름 같은 인간적인 상황**으로 표현합니다. + - 예: + - “피곤해서 산책을 조금 미룬다” + - “핸드폰을 보다가 강아지가 부르는 걸 바로 알아채지 못한다” + - “화장실 청소를 ‘조금만 있다가’ 하려다 까먹는다” + - health/happiness는 확실히 감소하지만, + **표현 자체는 부드럽고 자연스럽게** 적습니다. + - 옵션 세 가지 모두 “누가 봐도 정답/오답처럼 보이도록 만들지 말고 **모두 현실적인 인간적 상황처럼 보이도록 구성해야 합니다.** + - health/happiness는 선택지 난이도에 따라 다음 범위에서 변동합니다: + - 이상적인 선택: +4 ~ +12 + - 애매한 선택: +1 ~ -4 + - 인간적 실수/게으름 선택: -6 ~ -15 + (하지만 표현은 부드럽고 자연스럽게) + + ### 3. timeHour 규칙 + - 이번 턴의 timeHour는 **반드시 이전 턴(%d)보다 크거나 같아야 합니다.** + - timeHour가 이전 값보다 작아지는 경우(예: 21 → 9)는 허용되지 않습니다. + - 일반적으로 timeHour는 이전 값에서 1~3 정도만 증가시키는 것이 자연스럽습니다. + - 예: 이전 값이 10이면, 11 또는 12 또는 13 정도로 설정 + - 이번 턴이 게임 종료 턴이라면: + - timeHour는 **반드시 31 이상**이어야 합니다. + - 가능하면 31로 맞추는 것을 권장합니다. + + ### 4. 이번 턴에서 해야 할 일 + - 직전 상태(lastState)와 사용자의 실제 선택(id="%s", text="%s")을 기준으로 + 이번 턴의 message / timeHour / health / happiness / cost / finished / options / finalScore 값을 계산하세요. + - finished가 true인 경우 다음의 규칙을 반드시 지키세요. + - timeHour는 반드시 31 이상이어야 합니다. (가능하면 31로 맞추세요.) + - options는 반드시 빈 배열([])로 반환합니다. + - 종합 점수(finalScore)를 계산합니다. + - finalScore = (health + happiness) / 2 + - 소수점이 생기면 반올림하여 정수로 만듭니다. + - 예: health=90, happiness=80 → (90+80)/2 = 85 → finalScore=85 + - message 작성 규칙: + - 이번 턴까지의 전체 흐름을 요약하고, 건강/행복/비용의 최종 상태를 자연스럽게 설명합니다. + - 사용자가 반려동물을 키우기에 어떤지에 대한 평가 멘트를 한국어로 포함합니다. + - 예: "키우기 좋아요.", "다시 고민해보셔도 좋을 것 같아요.", "더 공부하셔야 될 것 같아요." 등 + - **message의 마지막 문장은 반드시 정확히 아래 문장으로 끝나야 합니다.** + - "시뮬레이션이 종료되었습니다." + - finished가 false인 경우: + - finalScore는 null이거나 생략해도 됩니다. + - 사용자가 다음에 할 수 있는 2~3개의 선택지를 제안하세요. + - options[].id는 "A", "B", "C" 순으로 사용하세요. + - options[].text에는 실제 행동 설명을 자연스럽게 한국어로 적으세요. + - 각 선택지는 동물의 관리 포인트(산책, 배변, 화장실, 새장 관리 등)를 반영해야 합니다. + + ### 5. 숫자 표기 규칙 (필수) + - timeHour, health, happiness, cost, finished 값은 반드시 **단일 리터럴 값**으로만 작성하세요. + - 예: 14, 85, 75, 82900, false + - 절대 이렇게 쓰면 안 됩니다: + - 2900 + 80000 + - 1,000 + - "3000원" + - "50" + - cost에는 "원"과 같은 단위를 붙이지 말고, 정수 숫자만 사용하세요. + + ### 6. 응답 JSON 형식 (반드시 지킬 것) + 아래 형식을 그대로 따르세요. + JSON 바깥에는 아무 설명도 쓰지 마세요. + ```json 같은 코드 블록도 절대 사용하지 마세요. + + { + "message": "상황 설명 (자연스러운 한국어 한 문단)", + "timeHour": 10, + "health": 95, + "happiness": 60, + "cost": 3000, + "finished": false, + "finalScore": 0, + "options": [ + { "id": "A", "text": "선택지1" }, + { "id": "B", "text": "선택지2" } + ] + } + """; + + // %s / %d 순서: 9개 인자 (문자열/정수 혼합) + return prompt.formatted( + breedName, + lastStateJson, // 1: lastState + lastOptionsJson, // 2: lastOptions + prevTimeHour, // 3: 직전 timeHour (int) + chosenOptionId, // 4: 직전 선택 id + escapeForPrompt(chosenOptionText), // 5: 직전 선택 text + chosenOptionId, // 6: 규칙용 id + escapeForPrompt(chosenOptionText), // 7: 규칙용 text + prevTimeHour, // 8: timeHour 규칙에 들어가는 이전 timeHour + chosenOptionId, // 9: 마지막 설명용 id (문장 내) + escapeForPrompt(chosenOptionText) // 10: 마지막 설명용 text (문장 내) + ); + } + + /** + * lastView를 프롬프트에 넣기 위한 간단 JSON 문자열 + * (우리가 Gemini에게 맥락을 보여주기 위한 용도라 대략적인 구조만 있으면 됨) + */ + private String toStateJsonForPrompt(GameStateView view) { + if (view == null) { + return "{}"; + } + return String.format( + "{ \"timeHour\": %d, \"health\": %d, \"happiness\": %d, \"cost\": %d, \"finished\": %b, \"message\": \"%s\" }", + view.getTimeHour(), + view.getHealth(), + view.getHappiness(), + view.getCost(), + view.isFinished(), + escapeForPrompt(view.getMessage()) + ); + } + + /** + * options 리스트를 프롬프트에 넣기 위한 JSON 비슷한 문자열 + */ + private String toOptionsJsonForPrompt(List options) { + if (options == null || options.isEmpty()) { + return "[]"; + } + StringBuilder sb = new StringBuilder(); + sb.append("["); + for (int i = 0; i < options.size(); i++) { + GameOption opt = options.get(i); + if (i > 0) { + sb.append(", "); + } + sb.append(String.format( + "{ \"id\": \"%s\", \"text\": \"%s\" }", + escapeForPrompt(opt.getId()), + escapeForPrompt(opt.getText()) + )); + } + sb.append("]"); + return sb.toString(); + } + + /** + * 프롬프트 안에 들어가는 문자열에서 큰따옴표/줄바꿈 정도만 간단히 이스케이프 + */ + private String escapeForPrompt(String text) { + if (text == null) { + return ""; + } + return text + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", " ") + .replace("\r", " "); + } +} diff --git a/src/main/java/org/mybatis/jpetstore/service/GameSessionRepository.java b/src/main/java/org/mybatis/jpetstore/service/GameSessionRepository.java new file mode 100644 index 000000000..6ce87ee14 --- /dev/null +++ b/src/main/java/org/mybatis/jpetstore/service/GameSessionRepository.java @@ -0,0 +1,34 @@ +package org.mybatis.jpetstore.service; + +import org.mybatis.jpetstore.domain.gamesimulation.GameSession; +import org.mybatis.jpetstore.mapper.GameSessionMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +public class GameSessionRepository { + + private final GameSessionMapper gameSessionMapper; + + public GameSessionRepository(GameSessionMapper mapper) { + this.gameSessionMapper = mapper; + } + + @Transactional + public void saveNew(GameSession session) { + gameSessionMapper.insertGameSession(session); + } + + @Transactional + public void update(GameSession session) { + gameSessionMapper.updateGameSession(session); + } + + @Transactional(readOnly = true) + public GameSession findById(String sessionId) { + return gameSessionMapper.getGameSession(sessionId); + } + +} diff --git a/src/main/java/org/mybatis/jpetstore/service/GameSimulationService.java b/src/main/java/org/mybatis/jpetstore/service/GameSimulationService.java new file mode 100644 index 000000000..e44dc1fca --- /dev/null +++ b/src/main/java/org/mybatis/jpetstore/service/GameSimulationService.java @@ -0,0 +1,153 @@ +package org.mybatis.jpetstore.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import org.mybatis.jpetstore.service.GeminiClient; +import org.mybatis.jpetstore.domain.gamesimulation.GameOption; +import org.mybatis.jpetstore.domain.gamesimulation.GameSession; +import org.mybatis.jpetstore.domain.gamesimulation.GameStateView; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Service; + +import java.util.*; + +@Service +public class GameSimulationService { + + private final GameSessionRepository gameSessionRepository; + private final GeminiClient geminiClient; + private final GamePromptBuilder promptBuilder; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public GameSimulationService(GameSessionRepository gameSessionRepository,GeminiClient geminiClient,GamePromptBuilder promptBuilder) { + this.gameSessionRepository = gameSessionRepository; + this.geminiClient = geminiClient; + this.promptBuilder = promptBuilder; + } + + public GameStateView startGame(String accountId, String breedId) throws JsonProcessingException { + String prompt = promptBuilder.buildStartPrompt(accountId, breedId); + String json = geminiClient.chat(prompt); // JSON 문자열 + + GameStateView view = parseGameStateJson(json); + + String sessionId = UUID.randomUUID().toString(); + GameSession session = new GameSession(); + session.setSessionId(sessionId); + session.setAccountId(accountId); + session.setBreedId(breedId); + session.setTimeHour(view.getTimeHour()); + session.setHealth(view.getHealth()); + session.setHappiness(view.getHappiness()); + session.setCost(view.getCost()); + session.setFinished(view.isFinished()); + session.setLastMessage(view.getMessage()); + //마지막 응답내용 저장 + List options = view.getOptions(); + if (options != null) { + String optionsJson = objectMapper.writeValueAsString(options); + session.setLastOptionJson(optionsJson); + } + + gameSessionRepository.saveNew(session); + + + view.setSessionId(sessionId); + return view; + } + + public GameStateView nextStep(String sessionId, String chosenOptionId) throws JsonProcessingException { + // 1. 세션 조회 + GameSession session = gameSessionRepository.findById(sessionId); + if (session == null) { + throw new IllegalArgumentException("GameSession not found: " + sessionId); + } + + // 2. 직전 상태를 GameStateView 형태로 만들어서 프롬프트에 넘길 준비 + GameStateView lastView = new GameStateView(); + lastView.setSessionId(sessionId); + lastView.setTimeHour(session.getTimeHour()); + lastView.setHealth(session.getHealth()); + lastView.setHappiness(session.getHappiness()); + lastView.setCost(session.getCost()); + lastView.setFinished(session.isFinished()); + lastView.setMessage(session.getLastMessage()); + + if (session.getLastOptionJson() != null) { + var type = objectMapper.getTypeFactory() + .constructCollectionType(List.class, GameOption.class); + List options = objectMapper.readValue(session.getLastOptionJson(), type); + lastView.setOptions(options); + } + + // 3. 프롬프트 생성 (GamePromptBuilder에 맞춰서) + String prompt = promptBuilder.buildNextPrompt(session, lastView, chosenOptionId); + + // 4. Gemini 호출해서 JSON 문자열 받기 + String json = geminiClient.chat(prompt); + + // 5. JSON → GameStateView 파싱 + GameStateView newView = parseGameStateJson(json); + newView.setSessionId(sessionId); // 세션 id 세팅 + // 최종점수 저장 + if (newView.isFinished()) { + session.setFinalScore(newView.getFinalScore()); + } + + // 6. 세션 상태 업데이트 후 DB 저장 + session.setTimeHour(newView.getTimeHour()); + session.setHealth(newView.getHealth()); + session.setHappiness(newView.getHappiness()); + session.setCost(newView.getCost()); + session.setFinished(newView.isFinished()); + session.setLastMessage(newView.getMessage()); + + if (newView.getOptions() != null) { + session.setLastOptionJson(objectMapper.writeValueAsString(newView.getOptions())); + } + else { + session.setLastOptionJson(null); + } + + gameSessionRepository.update(session); + + // 7. 프론트/챗봇으로 내려줄 새 상태 반환 + return newView; + } + + + private GameStateView parseGameStateJson(String json) { + try { + Map map = objectMapper.readValue(json, Map.class); + + GameStateView view = new GameStateView(); + view.setTimeHour((Integer) map.get("timeHour")); + view.setHealth((Integer) map.get("health")); + view.setHappiness((Integer) map.get("happiness")); + view.setCost((Integer) map.get("cost")); + view.setFinished((Boolean) map.get("finished")); + view.setMessage((String) map.get("message")); + + List> optList = + (List>) map.get("options"); + + if (optList != null) { + List options = new ArrayList<>(); + for (Map o : optList) { + String id = (String) o.get("id"); + String text = (String) o.get("text"); + options.add(new GameOption(id, text)); + } + view.setOptions(options); + } + + Object fs = map.get("finalScore"); + if (fs instanceof Number) { + view.setFinalScore(((Number) fs).intValue()); + } + + return view; + } catch (Exception e) { + throw new RuntimeException("Gemini JSON 파싱 실패: " + json, e); + } + } +} diff --git a/src/main/java/org/mybatis/jpetstore/service/GeminiClient.java b/src/main/java/org/mybatis/jpetstore/service/GeminiClient.java new file mode 100644 index 000000000..596f63ff4 --- /dev/null +++ b/src/main/java/org/mybatis/jpetstore/service/GeminiClient.java @@ -0,0 +1,176 @@ +package org.mybatis.jpetstore.service; + +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.web.client.RestTemplate; +import java.util.*; + +/** + * JPetStore 스타일의 단순 Gemini 클라이언트. + * - 동기 호출 + * - 예외는 RuntimeException으로 단순 처리 + * - 반환값: JSON 문자열 (우리 프롬프트에서 강제한 구조) + */ +public class GeminiClient { + + private String apiKey; // applicationContext.xml 에서 주입 + private String model; // 예: "gemini-2.0-flash" + private String baseUrl = "https://generativelanguage.googleapis.com"; // 기본값 + + private final RestTemplate restTemplate = new RestTemplate(); + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public void setModel(String model) { + this.model = model; + } + + public void setBaseUrl(String baseUrl) { + this.baseUrl = baseUrl; + } + + /** + * 기본 모델로 채팅 호출 + */ + public String chat(String prompt) { + return chatWithModel(prompt, this.model); + } + + /** + * 특정 모델명으로 호출하고 JSON 문자열만 반환 + */ + public String chatWithModel(String prompt, String modelName) { + if (apiKey == null || apiKey.isEmpty()) { + throw new IllegalStateException("Gemini API key is not set"); + } + if (modelName == null || modelName.isEmpty()) { + throw new IllegalStateException("Gemini model name is not set"); + } + + Map body = createRequestBody(prompt); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity> entity = new HttpEntity<>(body, headers); + + String url = baseUrl + "/v1beta/models/" + modelName + ":generateContent?key=" + apiKey; + + Map response = restTemplate.postForObject(url, entity, Map.class); + + String text = extractContent(response); + return extractJsonFromGeminiResponse(text); + } + + /** + * Gemini API 요청 body 생성 + */ + private Map createRequestBody(String prompt) { + Map body = new HashMap<>(); + + Map contents = new HashMap<>(); + List> parts = new ArrayList<>(); + + Map part = new HashMap<>(); + part.put("text", prompt); + parts.add(part); + + contents.put("parts", parts); + + List> contentsList = new ArrayList<>(); + contentsList.add(contents); + + body.put("contents", contentsList); + + // safetySettings (원하면 더 추가 가능) + Map safetySettings = new HashMap<>(); + safetySettings.put("category", "HARM_CATEGORY_HARASSMENT"); + safetySettings.put("threshold", "BLOCK_NONE"); + + List> safetySettingsList = new ArrayList<>(); + safetySettingsList.add(safetySettings); + + body.put("safetySettings", safetySettingsList); + + return body; + } + + /** + * Gemini 응답에서 candidates[0].content.parts[0].text 추출 + */ + @SuppressWarnings("unchecked") + private String extractContent(Map response) { + if (response == null || !response.containsKey("candidates")) { + throw new RuntimeException("Invalid Gemini response: no candidates"); + } + + List> candidates = + (List>) response.get("candidates"); + + if (candidates == null || candidates.isEmpty()) { + throw new RuntimeException("Invalid Gemini response: empty candidates"); + } + + Map candidate = candidates.get(0); + if (candidate == null || !candidate.containsKey("content")) { + throw new RuntimeException("Invalid Gemini response: missing content"); + } + + Map content = (Map) candidate.get("content"); + if (content == null || !content.containsKey("parts")) { + throw new RuntimeException("Invalid Gemini response: missing parts"); + } + + List> parts = + (List>) content.get("parts"); + + if (parts == null || parts.isEmpty()) { + throw new RuntimeException("Invalid Gemini response: empty parts"); + } + + Map firstPart = parts.get(0); + if (firstPart == null || !firstPart.containsKey("text")) { + throw new RuntimeException("Invalid Gemini response: missing text"); + } + + return (String) firstPart.get("text"); + } + + /** + * Gemini 응답 text에서 JSON 부분만 추출 + * - ```json ... ``` 코드블록 제거 + * - ``` ... ``` 코드블록 제거 + * - 코드블록 없으면 그대로 반환 + */ + private String extractJsonFromGeminiResponse(String response) { + if (response == null || response.trim().isEmpty()) { + throw new RuntimeException("Empty Gemini text response"); + } + + String trimmed = response.trim(); + + // ```json 으로 시작하는 경우 + if (trimmed.startsWith("```json")) { + int startIndex = trimmed.indexOf("```json") + 7; + int endIndex = trimmed.lastIndexOf("```"); + if (endIndex > startIndex) { + return trimmed.substring(startIndex, endIndex).trim(); + } + } + + // ``` 으로 시작 (json 태그 없이 코드블록) + if (trimmed.startsWith("```")) { + int startIndex = trimmed.indexOf("```") + 3; + int endIndex = trimmed.lastIndexOf("```"); + if (endIndex > startIndex) { + return trimmed.substring(startIndex, endIndex).trim(); + } + } + + // 코드블록 없으면 그대로 반환 (우리가 프롬프트에서 JSON만 나오게 강제하는 경우) + return trimmed; + } +} diff --git a/src/main/java/org/mybatis/jpetstore/web/actions/GameSimulationActionBean.java b/src/main/java/org/mybatis/jpetstore/web/actions/GameSimulationActionBean.java new file mode 100644 index 000000000..85afc766f --- /dev/null +++ b/src/main/java/org/mybatis/jpetstore/web/actions/GameSimulationActionBean.java @@ -0,0 +1,117 @@ +package org.mybatis.jpetstore.web.actions; + +import java.io.Serializable; +import javax.servlet.http.HttpSession; +import net.sourceforge.stripes.action.Resolution; +import net.sourceforge.stripes.action.SessionScope; +import net.sourceforge.stripes.action.StreamingResolution; +import net.sourceforge.stripes.integration.spring.SpringBean; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.mybatis.jpetstore.domain.gamesimulation.GameStateView; +import org.mybatis.jpetstore.service.GameSimulationService; + +/** + * 반려동물 돌봄 게임 시뮬레이션용 ActionBean. + * + * URL 예시: + * - 게임 시작: actions/GameSimulation.action?startGame=&breedId=CATS + * - 다음 턴: actions/GameSimulation.action?nextStep=&sessionId=...&optionId=A + */ +@SessionScope +public class GameSimulationActionBean extends AbstractActionBean implements Serializable { + + private static final long serialVersionUID = 1L; + + @SpringBean + private transient GameSimulationService gameSimulationService; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + // 요청 파라미터로 받을 것들 + private String breedId; // 고양이, 강아지… 품종/종류 ID (CATS, DOGS 등) + private String sessionId; // 게임 세션 식별자 + private String optionId; // 사용자가 고른 선택지 ID (A/B/C…) + + // ====== 파라미터 getter/setter (Stripes가 자동으로 바인딩) ====== + public String getBreedId() { + return breedId; + } + + public void setBreedId(String breedId) { + this.breedId = breedId; + } + + public String getSessionId() { + return sessionId; + } + + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } + + public String getOptionId() { + return optionId; + } + + public void setOptionId(String optionId) { + this.optionId = optionId; + } + + // ====== 게임 시작 (첫 턴) ====== + // 호출 예: /jpetstore/actions/GameSimulation.action?startGame=&breedId=CATS + public Resolution startGame() { + try { + String accountId = resolveCurrentUsername(); // 로그인 안 돼 있으면 GUEST 등으로 처리 + + GameStateView view = gameSimulationService.startGame(accountId, breedId); + + String json = objectMapper.writeValueAsString(view); + return new StreamingResolution("application/json", json); + } catch (Exception e) { + e.printStackTrace(); + // 실서비스면 공통 에러 JSON으로 빼는 게 좋음 + return new StreamingResolution("application/json", + "{\"error\":\"failed_to_start_game\"}"); + } + } + + // ====== 다음 턴 진행 ====== + // 호출 예: /jpetstore/actions/GameSimulation.action?nextStep=&sessionId=...&optionId=A + public Resolution nextStep() { + try { + String accountId = resolveCurrentUsername(); // 필요하면 사용 + + GameStateView view = gameSimulationService.nextStep(sessionId, optionId); + + String json = objectMapper.writeValueAsString(view); + return new StreamingResolution("application/json", json); + } catch (Exception e) { + e.printStackTrace(); + return new StreamingResolution("application/json", + "{\"error\":\"failed_to_continue_game\"}"); + } + } + + // ====== 세션에서 로그인한 유저 ID 가져오기 ====== + private String resolveCurrentUsername() { + HttpSession session = getContext().getRequest().getSession(false); + if (session == null) { + return "GUEST"; + } + + // jpetstore에서 AccountActionBean을 이렇게 세션에 넣어둠 + Object bean = session.getAttribute("/actions/Account.action"); + if (bean == null) { + bean = session.getAttribute("accountBean"); + } + + if (bean instanceof AccountActionBean) { + AccountActionBean accountBean = (AccountActionBean) bean; + if (accountBean.isAuthenticated() && accountBean.getAccount() != null) { + return accountBean.getAccount().getUsername(); + } + } + + return "GUEST"; + } +} diff --git a/src/main/resources/database/jpetstore-hsqldb-schema.sql b/src/main/resources/database/jpetstore-hsqldb-schema.sql index 6b51720e5..5bc97f670 100644 --- a/src/main/resources/database/jpetstore-hsqldb-schema.sql +++ b/src/main/resources/database/jpetstore-hsqldb-schema.sql @@ -163,3 +163,19 @@ CREATE TABLE sequence nextid int not null, constraint pk_sequence primary key (name) ); + +CREATE TABLE GAME_SESSION ( + SESSION_ID VARCHAR(64) NOT NULL, + ACCOUNT_ID VARCHAR(80) NOT NULL, + BREED_ID VARCHAR(40) NOT NULL, + TIME_HOUR INTEGER NOT NULL, + HEALTH INTEGER NOT NULL, + HAPPINESS INTEGER NOT NULL, + COST INTEGER NOT NULL, + FINISHED BOOLEAN NOT NULL, + LAST_MESSAGE VARCHAR(4000), + LAST_OPTION_JSON VARCHAR(4000), + FINAL_SCORE INTEGER, + CONSTRAINT PK_GAME_SESSION PRIMARY KEY (SESSION_ID) +); + diff --git a/src/main/resources/org/mybatis/jpetstore/mapper/GameSessionMapper.xml b/src/main/resources/org/mybatis/jpetstore/mapper/GameSessionMapper.xml new file mode 100644 index 000000000..abc5c883e --- /dev/null +++ b/src/main/resources/org/mybatis/jpetstore/mapper/GameSessionMapper.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO GAME_SESSION ( + SESSION_ID, + ACCOUNT_ID, + BREED_ID, + TIME_HOUR, + HEALTH, + HAPPINESS, + COST, + FINISHED, + LAST_MESSAGE, + LAST_OPTION_JSON, + FINAL_SCORE + ) VALUES ( + #{sessionId}, + #{accountId}, + #{breedId}, + #{timeHour}, + #{health}, + #{happiness}, + #{cost}, + #{finished}, + #{lastMessage}, + #{lastOptionJson}, + #{finalScore} + ) + + + + UPDATE GAME_SESSION + SET + TIME_HOUR = #{timeHour}, + HEALTH = #{health}, + HAPPINESS = #{happiness}, + COST = #{cost}, + FINISHED = #{finished}, + LAST_MESSAGE = #{lastMessage}, + LAST_OPTION_JSON = #{lastOptionJson}, + FINAL_SCORE = #{finalScore} + WHERE SESSION_ID = #{sessionId} + + + diff --git a/src/main/webapp/WEB-INF/applicationContext.xml b/src/main/webapp/WEB-INF/applicationContext.xml index 53d76e86d..6d32b5091 100644 --- a/src/main/webapp/WEB-INF/applicationContext.xml +++ b/src/main/webapp/WEB-INF/applicationContext.xml @@ -52,4 +52,14 @@ + + + + + + + + + + diff --git a/src/main/webapp/WEB-INF/jsp/account/EditAccountForm.jsp b/src/main/webapp/WEB-INF/jsp/account/EditAccountForm.jsp index df6a79bfa..9ea96e87a 100644 --- a/src/main/webapp/WEB-INF/jsp/account/EditAccountForm.jsp +++ b/src/main/webapp/WEB-INF/jsp/account/EditAccountForm.jsp @@ -15,6 +15,10 @@ limitations under the License. --%> +<%@ page language="java" + contentType="text/html; charset=UTF-8" + pageEncoding="UTF-8" %> + <%@ include file="../common/IncludeTop.jsp"%>
+<%@ page language="java" + contentType="text/html; charset=UTF-8" + pageEncoding="UTF-8" %> +

Account Information

diff --git a/src/main/webapp/WEB-INF/jsp/account/NewAccountForm.jsp b/src/main/webapp/WEB-INF/jsp/account/NewAccountForm.jsp index 98b1a5749..8f6335f78 100644 --- a/src/main/webapp/WEB-INF/jsp/account/NewAccountForm.jsp +++ b/src/main/webapp/WEB-INF/jsp/account/NewAccountForm.jsp @@ -15,6 +15,10 @@ limitations under the License. --%> +<%@ page language="java" + contentType="text/html; charset=UTF-8" + pageEncoding="UTF-8" %> + <%@ include file="../common/IncludeTop.jsp"%>
+<%@ page language="java" + contentType="text/html; charset=UTF-8" + pageEncoding="UTF-8" %> + <%@ include file="../common/IncludeTop.jsp"%>
+<%@ page language="java" + contentType="text/html; charset=UTF-8" + pageEncoding="UTF-8" %> + <%@ include file="../common/IncludeTop.jsp"%>