diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 000000000..dce887f0c --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,88 @@ +name: Deploy to Oracle Cloud + +on: + push: + branches: + - master + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + cache: 'maven' + + - name: Build with Maven + run: ./mvnw clean package -DskipTests + + - name: Deploy to Oracle Cloud + env: + ORACLE_HOST: ${{ secrets.ORACLE_HOST }} + ORACLE_USER: ${{ secrets.ORACLE_USER }} + ORACLE_SSH_KEY: ${{ secrets.ORACLE_SSH_KEY }} + GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} + run: | + # SSH 키 설정 + mkdir -p ~/.ssh + echo "$ORACLE_SSH_KEY" > ~/.ssh/oracle_key + chmod 600 ~/.ssh/oracle_key + + # known_hosts에 호스트 추가 + ssh-keyscan -H $ORACLE_HOST >> ~/.ssh/known_hosts + + # WAR 파일을 서버로 전송 + scp -i ~/.ssh/oracle_key target/jpetstore.war $ORACLE_USER@$ORACLE_HOST:/tmp/ + + # 서버에서 배포 스크립트 실행 + ssh -i ~/.ssh/oracle_key $ORACLE_USER@$ORACLE_HOST << 'ENDSSH' + # Tomcat 중지 + sudo systemctl stop tomcat || true + + # 기존 애플리케이션 백업 및 삭제 + sudo rm -rf /opt/tomcat/webapps/jpetstore + sudo rm -f /opt/tomcat/webapps/jpetstore.war + + # 새 WAR 파일 배포 + sudo mv /tmp/jpetstore.war /opt/tomcat/webapps/ + sudo chown tomcat:tomcat /opt/tomcat/webapps/jpetstore.war + + # 환경 변수 설정 (GEMINI_API_KEY) + echo "GEMINI_API_KEY=$GEMINI_API_KEY" | sudo tee /opt/tomcat/bin/setenv.sh + sudo chmod +x /opt/tomcat/bin/setenv.sh + + # Tomcat 시작 + sudo systemctl start tomcat + + # 상태 확인 + sleep 10 + sudo systemctl status tomcat + ENDSSH + + - name: Health Check + env: + ORACLE_HOST: ${{ secrets.ORACLE_HOST }} + run: | + echo "Waiting for application to start..." + sleep 30 + + # 헬스 체크 (최대 5번 시도) + for i in {1..5}; do + if curl -f http://$ORACLE_HOST:8080/jpetstore/; then + echo "✅ Application is running!" + exit 0 + fi + echo "Attempt $i failed, retrying..." + sleep 10 + done + + echo "❌ Health check failed" + exit 1 \ No newline at end of file diff --git a/AI_SETUP_GUIDE.txt b/AI_SETUP_GUIDE.txt new file mode 100644 index 000000000..b2ff1afaa --- /dev/null +++ b/AI_SETUP_GUIDE.txt @@ -0,0 +1,238 @@ +==== + 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. +==== + +======================================== + Google Gemini AI 추천 기능 설정 가이드 +======================================== + +📋 목차 +1. Google Gemini API 키 발급 +2. API 키 설정 방법 +3. 애플리케이션 실행 +4. 기능 테스트 +5. 데모 모드 (API 키 없이 사용) + +======================================== +1. Google Gemini API 키 발급 +======================================== + +🔗 https://ai.google.dev/ 접속 + +1) Google 계정으로 로그인 +2) "Get API Key" 버튼 클릭 +3) "Create API Key" 선택 +4) API 키 복사 (예: AIzaSyABC123...XYZ) + +💰 비용: 완전 무료 (일일 1,500회 요청 제한) +⏱️ 소요 시간: 약 3분 + +======================================== +2. API 키 설정 방법 +======================================== + +방법 A) macOS/Linux 환경 변수 설정 (추천) +------------------------------------------ +터미널에서 다음 명령어 실행: + +export GEMINI_API_KEY="여기에_발급받은_API_키_입력" + +예시: +export GEMINI_API_KEY="AIzaSyABC123def456GHI789jkl012MNO345pqr" + +* 영구 설정하려면 ~/.zshrc 또는 ~/.bash_profile에 추가: +echo 'export GEMINI_API_KEY="여기에_발급받은_API_키_입력"' >> ~/.zshrc +source ~/.zshrc + + +방법 B) Windows 환경 변수 설정 +------------------------------- +명령 프롬프트(CMD)에서: + +set GEMINI_API_KEY=여기에_발급받은_API_키_입력 + +PowerShell에서: + +$env:GEMINI_API_KEY="여기에_발급받은_API_키_입력" + + +방법 C) IntelliJ IDEA에서 설정 +-------------------------------- +1) Run > Edit Configurations +2) Environment variables 항목 찾기 +3) 다음 추가: GEMINI_API_KEY=여기에_발급받은_API_키_입력 + +======================================== +3. 애플리케이션 실행 +======================================== + +터미널에서 프로젝트 루트 디렉토리로 이동 후: + +# API 키 설정 (방법 A 참고) +export GEMINI_API_KEY="여기에_발급받은_API_키_입력" + +# Maven으로 빌드 및 실행 +mvn clean package +mvn cargo:run + +# 또는 한 줄로 +mvn clean package && mvn cargo:run + +✅ 서버 시작 완료 메시지 확인: +[INFO] Press Ctrl-C to stop the container... + +======================================== +4. 기능 테스트 +======================================== + +1) 브라우저에서 접속: + http://localhost:8080/jpetstore + +2) 메인 페이지에서 카테고리 선택 (예: FISH, DOGS, CATS 등) + +3) 원하는 상품 클릭 + +4) 상품 목록에서 특정 Item ID 클릭 + +5) 상품 상세 페이지 하단에 "🤖 AI 추천 상품" 섹션 확인 + +📝 예상 결과: +------------------------------------------ +[상품 정보] +Large Angelfish +$16.50 +[Add to Cart 버튼] + +🤖 AI 추천 상품 +이 상품과 함께 구매하면 좋은 제품을 AI가 추천해드립니다 + +1. 고급 어류 사료: 물고기의 건강한 성장과... +2. 수족관 여과 시스템: 깨끗한 수질 관리로... +3. 수초 세트: 자연스러운 서식 환경을... + +💡 Powered by Google Gemini AI +------------------------------------------ + +⏱️ 로딩 시간: 1-3초 (API 응답 대기) + +======================================== +5. 데모 모드 (API 키 없이 사용) +======================================== + +API 키를 설정하지 않아도 기능이 동작합니다! + +📌 동작 방식: +- API 키가 없으면 자동으로 데모 모드로 전환 +- 카테고리별로 미리 작성된 추천 내용 표시 +- 실제 API 호출 없이 즉시 표시 + +💡 데모 모드 활용: +- 발표 준비 시 API 키 없이 먼저 테스트 +- 네트워크 문제 시 대체 수단 +- API 할당량 초과 시 백업 + +======================================== +🔧 문제 해결 (Troubleshooting) +======================================== + +문제 1) AI 추천이 표시되지 않음 +해결: +- 환경 변수가 제대로 설정되었는지 확인: + echo $GEMINI_API_KEY +- 서버를 재시작했는지 확인 +- 브라우저 캐시 삭제 (Ctrl+Shift+R) + +문제 2) API 에러 메시지 (403, 400 등) +해결: +- API 키가 올바른지 확인 +- https://ai.google.dev/에서 API 키 활성화 상태 확인 +- 일일 할당량 초과 여부 확인 + +문제 3) 로딩이 너무 느림 (5초 이상) +해결: +- 네트워크 연결 확인 +- Gemini API 서버 상태 확인 +- 데모 모드로 전환 고려 + +======================================== +📊 발표 시나리오 +======================================== + +발표 시 추천 순서: + +1. 데모 모드로 먼저 시연 + "먼저 AI 기능이 어떻게 동작하는지 보여드리겠습니다" + +2. 실제 API 연동 설명 + "실제로는 Google Gemini API를 실시간으로 호출합니다" + +3. 코드 구조 설명 + - AIRecommendationService.java: API 호출 로직 + - CatalogActionBean.java: 컨트롤러 통합 + - Item.jsp: UI 표시 + +4. 기술적 특징 강조 + - 무료 API 사용 + - 실시간 AI 분석 + - 에러 처리 및 폴백 메커니즘 + - 기존 MVC 패턴과 완벽 통합 + +======================================== +📚 참고 자료 +======================================== + +- Google Gemini API 문서: + https://ai.google.dev/docs + +- API 키 관리: + https://makersuite.google.com/app/apikey + +- JPetStore 프로젝트: + https://github.com/mybatis/jpetstore-6 + +======================================== +✅ 체크리스트 +======================================== + +설치 전: +[ ] Google 계정 준비 +[ ] 인터넷 연결 확인 + +설치: +[ ] API 키 발급 완료 +[ ] 환경 변수 설정 +[ ] 프로젝트 빌드 성공 + +테스트: +[ ] 서버 실행 확인 +[ ] 상품 페이지 접속 +[ ] AI 추천 섹션 표시 확인 +[ ] 추천 내용이 적절한지 확인 + +발표 준비: +[ ] 데모 시나리오 작성 +[ ] 네트워크 연결 확인 +[ ] 백업 플랜 준비 (데모 모드) + +======================================== +🎉 완료! +======================================== + +모든 설정이 완료되었습니다! +즐거운 발표 되세요! 😊 + +문의사항이 있으면 코드 주석을 참고하세요. +AIRecommendationService.java 파일에 +자세한 설명이 있습니다. diff --git a/DEPLOYMENT_GUIDE.md b/DEPLOYMENT_GUIDE.md new file mode 100644 index 000000000..e9cf38805 --- /dev/null +++ b/DEPLOYMENT_GUIDE.md @@ -0,0 +1,298 @@ +# Oracle Cloud 프리티어 배포 가이드 + +JPetStore 애플리케이션을 Oracle Cloud 프리티어에 배포하는 방법입니다. + +## 📋 사전 준비 + +1. **Oracle Cloud 계정** (프리티어 사용 가능) +2. **GitHub 계정** 및 리포지토리 (이미 설정됨: https://github.com/Jeong-Ryeol/jpetstore-6.git) +3. **Google Gemini API 키** (이미 GitHub Secrets에 추가됨) + +--- + +## 1️⃣ Oracle Cloud VM 인스턴스 생성 + +### 1-1. Oracle Cloud 콘솔 로그인 +- https://cloud.oracle.com 접속 +- 계정 로그인 + +### 1-2. Compute Instance 생성 +1. **메뉴** > **Compute** > **Instances** 클릭 +2. **Create Instance** 버튼 클릭 +3. 다음 설정: + - **Name**: `jpetstore-server` (또는 원하는 이름) + - **Image**: `Oracle Linux 8` (또는 최신 버전) + - **Shape**: `VM.Standard.E2.1.Micro` (프리티어 Always Free) + - **SSH keys**: 새로 생성하거나 기존 SSH 키 업로드 + - ⚠️ **중요**: Private Key를 반드시 저장하세요! +4. **Create** 클릭 + +### 1-3. 공인 IP 확인 +- 인스턴스 생성 완료 후 **Public IP Address** 확인 +- 예: `123.45.67.89` + +--- + +## 2️⃣ 방화벽 규칙 설정 (8080 포트 열기) + +### 2-1. Security List 설정 +1. Oracle Cloud 콘솔에서: + - **Networking** > **Virtual Cloud Networks** 클릭 + - VCN 선택 (인스턴스가 있는 VCN) + - **Security Lists** 클릭 + - Default Security List 선택 + +2. **Ingress Rules** 추가: + - **Add Ingress Rules** 클릭 + - 다음 입력: + - **Source CIDR**: `0.0.0.0/0` + - **IP Protocol**: `TCP` + - **Destination Port Range**: `8080` + - **Add Ingress Rules** 클릭 + +--- + +## 3️⃣ VM 초기 설정 + +### 3-1. SSH로 VM 접속 +```bash +ssh -i /path/to/your-private-key opc@ +``` + +예시: +```bash +ssh -i ~/.ssh/oracle_key opc@123.45.67.89 +``` + +### 3-2. 설정 스크립트 실행 +```bash +# 스크립트 다운로드 +curl -o setup-oracle-vm.sh https://raw.githubusercontent.com/Jeong-Ryeol/jpetstore-6/master/scripts/setup-oracle-vm.sh + +# 실행 권한 부여 +chmod +x setup-oracle-vm.sh + +# 실행 (sudo 필요) +sudo bash setup-oracle-vm.sh +``` + +스크립트가 자동으로 다음을 설치합니다: +- ✅ Java 17 +- ✅ Apache Tomcat 9.0.105 +- ✅ 방화벽 설정 +- ✅ Systemd 서비스 등록 + +### 3-3. 설치 확인 +```bash +# Tomcat 상태 확인 +sudo systemctl status tomcat + +# 로그 확인 +sudo tail -f /opt/tomcat/logs/catalina.out +``` + +--- + +## 4️⃣ GitHub Secrets 설정 + +GitHub 리포지토리에 다음 Secrets를 추가해야 합니다: + +1. **GitHub 리포지토리** 접속: https://github.com/Jeong-Ryeol/jpetstore-6 +2. **Settings** > **Secrets and variables** > **Actions** 클릭 +3. **New repository secret** 클릭하여 다음 추가: + +| Secret 이름 | 설명 | 예시 | +|------------|------|------| +| `GEMINI_API_KEY` | ✅ 이미 추가됨 | `AIzaSy...` | +| `ORACLE_HOST` | VM의 공인 IP | `123.45.67.89` | +| `ORACLE_USER` | SSH 사용자 (보통 `opc`) | `opc` | +| `ORACLE_SSH_KEY` | SSH Private Key 전체 내용 | `-----BEGIN RSA PRIVATE KEY-----\n...` | + +### ORACLE_SSH_KEY 추가 방법: +```bash +# 로컬 PC에서 SSH private key 내용 복사 +cat ~/.ssh/oracle_key + +# 출력된 전체 내용을 GitHub Secret에 붙여넣기 +# -----BEGIN RSA PRIVATE KEY----- 부터 +# -----END RSA PRIVATE KEY----- 까지 모두 포함 +``` + +--- + +## 5️⃣ 배포 실행 + +### 5-1. 자동 배포 (GitHub Actions) +- `master` 브랜치에 코드를 푸시하면 자동으로 배포됩니다: + +```bash +git add . +git commit -m "Deploy to Oracle Cloud" +git push origin master +``` + +### 5-2. 수동 배포 +GitHub 리포지토리에서: +1. **Actions** 탭 클릭 +2. **Deploy to Oracle Cloud** 워크플로우 선택 +3. **Run workflow** 버튼 클릭 +4. `master` 브랜치 선택 후 **Run workflow** + +### 5-3. 배포 상태 확인 +- **Actions** 탭에서 워크플로우 실행 상태 확인 +- 로그에서 각 단계 확인 가능 + +--- + +## 6️⃣ 애플리케이션 접속 + +배포가 완료되면 브라우저에서 접속: + +``` +http://:8080/jpetstore +``` + +예시: +``` +http://123.45.67.89:8080/jpetstore +``` + +--- + +## 🔧 문제 해결 + +### 1. 애플리케이션에 접속이 안 되는 경우 + +**방화벽 확인:** +```bash +# VM에 SSH 접속 후 +sudo firewall-cmd --list-all + +# 8080 포트가 없으면 추가 +sudo firewall-cmd --permanent --add-port=8080/tcp +sudo firewall-cmd --reload +``` + +**Oracle Cloud Security List 확인:** +- Oracle Cloud 콘솔에서 Ingress Rule에 8080 포트가 있는지 확인 + +**Tomcat 상태 확인:** +```bash +sudo systemctl status tomcat + +# 로그 확인 +sudo tail -100 /opt/tomcat/logs/catalina.out +``` + +### 2. 배포가 실패하는 경우 + +**GitHub Actions 로그 확인:** +- Actions 탭에서 실패한 워크플로우 클릭 +- 각 단계의 로그 확인 + +**SSH 연결 문제:** +- `ORACLE_SSH_KEY` Secret이 올바르게 설정되었는지 확인 +- SSH 키에 줄바꿈(`\n`)이 포함되어 있는지 확인 + +**권한 문제:** +```bash +# VM에서 Tomcat 디렉토리 권한 확인 +ls -la /opt/tomcat/webapps/ + +# 권한 수정 +sudo chown -R tomcat:tomcat /opt/tomcat/ +``` + +### 3. AI 챗봇이 작동하지 않는 경우 + +**환경 변수 확인:** +```bash +# VM에서 +cat /opt/tomcat/bin/setenv.sh + +# GEMINI_API_KEY가 설정되어 있어야 함 +``` + +**Tomcat 재시작:** +```bash +sudo systemctl restart tomcat +``` + +**로그 확인:** +```bash +sudo grep -i "gemini" /opt/tomcat/logs/catalina.out +``` + +--- + +## 📊 모니터링 + +### Tomcat 로그 실시간 확인 +```bash +sudo tail -f /opt/tomcat/logs/catalina.out +``` + +### 시스템 리소스 확인 +```bash +# CPU/메모리 사용량 +top + +# 디스크 사용량 +df -h +``` + +### 애플리케이션 상태 확인 +```bash +curl http://localhost:8080/jpetstore/ +``` + +--- + +## 🔄 업데이트 배포 + +코드를 수정한 후: + +```bash +git add . +git commit -m "Update feature" +git push origin master +``` + +GitHub Actions가 자동으로: +1. 코드 빌드 +2. Oracle Cloud VM에 배포 +3. Tomcat 재시작 +4. Health Check 수행 + +--- + +## 📝 주요 명령어 요약 + +```bash +# Tomcat 관리 +sudo systemctl start tomcat # 시작 +sudo systemctl stop tomcat # 중지 +sudo systemctl restart tomcat # 재시작 +sudo systemctl status tomcat # 상태 확인 + +# 로그 확인 +sudo tail -f /opt/tomcat/logs/catalina.out + +# 애플리케이션 확인 +curl http://localhost:8080/jpetstore/ + +# 방화벽 확인 +sudo firewall-cmd --list-all +``` + +--- + +## 🎉 완료! + +이제 JPetStore 애플리케이션이 Oracle Cloud에 배포되었습니다! + +- **애플리케이션**: http://:8080/jpetstore +- **AI 챗봇**: 우측 하단 💬 버튼 +- **GitHub 리포지토리**: https://github.com/Jeong-Ryeol/jpetstore-6 + +문제가 발생하면 위의 **문제 해결** 섹션을 참고하세요. 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/README.jpetstore.md b/README.jpetstore.md new file mode 100644 index 000000000..4cb6a7425 --- /dev/null +++ b/README.jpetstore.md @@ -0,0 +1,84 @@ +MyBatis JPetStore +================= + +[![Java CI](https://github.com/mybatis/jpetstore-6/actions/workflows/ci.yaml/badge.svg)](https://github.com/mybatis/jpetstore-6/actions/workflows/ci.yaml) +[![Container Support](https://github.com/mybatis/jpetstore-6/actions/workflows/support.yaml/badge.svg)](https://github.com/mybatis/jpetstore-6/actions/workflows/support.yaml) +[![Coverage Status](https://coveralls.io/repos/github/mybatis/jpetstore-6/badge.svg?branch=master)](https://coveralls.io/github/mybatis/jpetstore-6?branch=master) +[![License](https://img.shields.io/:license-apache-brightgreen.svg)](https://www.apache.org/licenses/LICENSE-2.0.html) + +![mybatis-jpetstore](https://mybatis.org/images/mybatis-logo.png) + +JPetStore 6 is a full web application built on top of MyBatis 3, Spring 5 and Stripes. + +Essentials +---------- + +* [See the docs](http://www.mybatis.org/jpetstore-6) + +## Other versions that you may want to know about + +- JPetstore on top of Spring, Spring MVC, MyBatis 3, and Spring Security https://github.com/making/spring-jpetstore +- JPetstore with Vaadin and Spring Boot with Java Config https://github.com/igor-baiborodine/jpetstore-6-vaadin-spring-boot +- JPetstore on MyBatis Spring Boot Starter https://github.com/kazuki43zoo/mybatis-spring-boot-jpetstore + +## Run on Application Server +Running JPetStore sample under Tomcat (using the [cargo-maven2-plugin](https://codehaus-cargo.github.io/cargo/Maven2+plugin.html)). + +- Clone this repository + + ``` + $ git clone https://github.com/mybatis/jpetstore-6.git + ``` + +- Build war file + + ``` + $ cd jpetstore-6 + $ ./mvnw clean package + ``` + +- Startup the Tomcat server and deploy web application + + ``` + $ ./mvnw cargo:run -P tomcat90 + ``` + + > Note: + > + > We provide maven profiles per application server as follow: + > + > | Profile | Description | + > | -------------- | ----------- | + > | tomcat90 | Running under the Tomcat 9.0 | + > | tomcat85 | Running under the Tomcat 8.5 | + > | tomee80 | Running under the TomEE 8.0(Java EE 8) | + > | tomee71 | Running under the TomEE 7.1(Java EE 7) | + > | wildfly26 | Running under the WildFly 26(Java EE 8) | + > | wildfly13 | Running under the WildFly 13(Java EE 7) | + > | liberty-ee8 | Running under the WebSphere Liberty(Java EE 8) | + > | liberty-ee7 | Running under the WebSphere Liberty(Java EE 7) | + > | jetty | Running under the Jetty 9 | + > | glassfish5 | Running under the GlassFish 5(Java EE 8) | + > | glassfish4 | Running under the GlassFish 4(Java EE 7) | + > | resin | Running under the Resin 4 | + +- Run application in browser http://localhost:8080/jpetstore/ +- Press Ctrl-C to stop the server. + +## Run on Docker +``` +docker build . -t jpetstore +docker run -p 8080:8080 jpetstore +``` +or with Docker Compose: +``` +docker compose up -d +``` + +## Try integration tests + +Perform integration tests for screen transition. + +``` +$ ./mvnw clean verify -P tomcat90 +``` diff --git a/README.md b/README.md index 4cb6a7425..acb1064a7 100644 --- a/README.md +++ b/README.md @@ -1,84 +1,561 @@ -MyBatis JPetStore -================= +# JPetStore - "모든 상품 보기" 기능 추가 -[![Java CI](https://github.com/mybatis/jpetstore-6/actions/workflows/ci.yaml/badge.svg)](https://github.com/mybatis/jpetstore-6/actions/workflows/ci.yaml) -[![Container Support](https://github.com/mybatis/jpetstore-6/actions/workflows/support.yaml/badge.svg)](https://github.com/mybatis/jpetstore-6/actions/workflows/support.yaml) -[![Coverage Status](https://coveralls.io/repos/github/mybatis/jpetstore-6/badge.svg?branch=master)](https://coveralls.io/github/mybatis/jpetstore-6?branch=master) -[![License](https://img.shields.io/:license-apache-brightgreen.svg)](https://www.apache.org/licenses/LICENSE-2.0.html) +## 📋 프로젝트 개요 -![mybatis-jpetstore](https://mybatis.org/images/mybatis-logo.png) +### 프로젝트명 +JPetStore 기능 개선 - 전체 상품 조회 시스템 -JPetStore 6 is a full web application built on top of MyBatis 3, Spring 5 and Stripes. +### 개발 기간 +2025년 10월 -Essentials ----------- +### 개발 목표 +사용자가 카테고리 구분 없이 모든 상품을 한 화면에서 조회할 수 있는 기능 구현 -* [See the docs](http://www.mybatis.org/jpetstore-6) +--- -## Other versions that you may want to know about +## 🎯 개발 배경 및 필요성 -- JPetstore on top of Spring, Spring MVC, MyBatis 3, and Spring Security https://github.com/making/spring-jpetstore -- JPetstore with Vaadin and Spring Boot with Java Config https://github.com/igor-baiborodine/jpetstore-6-vaadin-spring-boot -- JPetstore on MyBatis Spring Boot Starter https://github.com/kazuki43zoo/mybatis-spring-boot-jpetstore +### 기존 시스템의 문제점 +1. **카테고리별 제한된 조회**: 사용자가 특정 카테고리 내의 상품만 확인 가능 +2. **탐색 비효율성**: 전체 상품을 확인하려면 여러 카테고리를 순차적으로 방문해야 함 +3. **사용자 경험 저하**: 상품 비교 및 선택 시 불편함 -## Run on Application Server -Running JPetStore sample under Tomcat (using the [cargo-maven2-plugin](https://codehaus-cargo.github.io/cargo/Maven2+plugin.html)). +### 개선 목표 +- 모든 카테고리의 상품을 한 번에 조회할 수 있는 통합 뷰 제공 +- 직관적인 UI/UX로 접근성 향상 +- 기존 시스템과의 일관성 유지 -- Clone this repository +--- - ``` - $ git clone https://github.com/mybatis/jpetstore-6.git - ``` +## 💻 기술 스택 -- Build war file +### 백엔드 +- **Framework**: Spring Framework +- **ORM**: MyBatis 3.x +- **Language**: Java - ``` - $ cd jpetstore-6 - $ ./mvnw clean package - ``` +### 프론트엔드 +- **View Template**: JSP (JavaServer Pages) +- **Framework**: Stripes Framework +- **Library**: JSTL (JSP Standard Tag Library) -- Startup the Tomcat server and deploy web application +### 데이터베이스 +- **Query**: SQL (JOIN 쿼리 활용) +- **테이블**: ITEM, PRODUCT - ``` - $ ./mvnw cargo:run -P tomcat90 - ``` +--- - > Note: - > - > We provide maven profiles per application server as follow: - > - > | Profile | Description | - > | -------------- | ----------- | - > | tomcat90 | Running under the Tomcat 9.0 | - > | tomcat85 | Running under the Tomcat 8.5 | - > | tomee80 | Running under the TomEE 8.0(Java EE 8) | - > | tomee71 | Running under the TomEE 7.1(Java EE 7) | - > | wildfly26 | Running under the WildFly 26(Java EE 8) | - > | wildfly13 | Running under the WildFly 13(Java EE 7) | - > | liberty-ee8 | Running under the WebSphere Liberty(Java EE 8) | - > | liberty-ee7 | Running under the WebSphere Liberty(Java EE 7) | - > | jetty | Running under the Jetty 9 | - > | glassfish5 | Running under the GlassFish 5(Java EE 8) | - > | glassfish4 | Running under the GlassFish 4(Java EE 7) | - > | resin | Running under the Resin 4 | +## 🏗️ 시스템 아키텍처 -- Run application in browser http://localhost:8080/jpetstore/ -- Press Ctrl-C to stop the server. +### MVC 패턴 기반 구조 -## Run on Docker ``` -docker build . -t jpetstore -docker run -p 8080:8080 jpetstore +┌─────────────┐ +│ View │ AllItems.jsp, Main.jsp, IncludeTop.jsp +│ (JSP) │ +└──────┬──────┘ + │ + ↓ +┌─────────────┐ +│ Controller │ CatalogActionBean.java +│ (Action) │ - viewAllItems() +└──────┬──────┘ + │ + ↓ +┌─────────────┐ +│ Service │ CatalogService.java +│ (Business) │ - getAllItems() +└──────┬──────┘ + │ + ↓ +┌─────────────┐ +│ Mapper │ ItemMapper.java / ItemMapper.xml +│ (DAO) │ - getAllItems() SQL +└──────┬──────┘ + │ + ↓ +┌─────────────┐ +│ Database │ ITEM, PRODUCT 테이블 +└─────────────┘ ``` -or with Docker Compose: + +--- + +## 📝 상세 구현 내용 + +### 1. 데이터 접근 계층 (DAO) + +#### ItemMapper.java +```java +public interface ItemMapper { + // 기존 메서드들... + + /** + * 모든 상품 조회 + * @return 전체 상품 목록 + */ + List getAllItems(); +} +``` + +#### ItemMapper.xml - SQL 쿼리 +```xml + +``` + +**쿼리 특징**: +- `ITEM`과 `PRODUCT` 테이블 조인으로 완전한 상품 정보 조회 +- `ORDER BY`로 데이터 정렬 보장 +- MyBatis resultType 매핑 활용 + +--- + +### 2. 비즈니스 로직 계층 (Service) + +#### CatalogService.java +```java +@Service +public class CatalogService { + @Autowired + private ItemMapper itemMapper; + + // 기존 메서드들... + + /** + * 모든 상품 조회 + * @return 전체 상품 목록 + */ + public List getAllItems() { + return itemMapper.getAllItems(); + } +} +``` + +**역할**: +- Mapper 계층과 Controller 계층 사이의 중간 계층 +- 향후 비즈니스 로직 추가 가능 (필터링, 캐싱 등) + +--- + +### 3. 컨트롤러 계층 (Action) + +#### CatalogActionBean.java +```java +public class CatalogActionBean extends AbstractActionBean { + + // 뷰 경로 상수 추가 + private static final String VIEW_ALL_ITEMS = "/WEB-INF/jsp/catalog/AllItems.jsp"; + + @SpringBean + private transient CatalogService catalogService; + + private List itemList; + + /** + * 모든 상품 조회 액션 + * @return AllItems.jsp로 포워딩 + */ + public ForwardResolution viewAllItems() { + itemList = catalogService.getAllItems(); + return new ForwardResolution(VIEW_ALL_ITEMS); + } + + // Getter/Setter + public List getItemList() { + return itemList; + } +} +``` + +**처리 흐름**: +1. 사용자 요청 수신 +2. `CatalogService.getAllItems()` 호출 +3. 조회된 상품 목록을 `itemList`에 저장 +4. `AllItems.jsp`로 포워딩 + +--- + +### 4. 프레젠테이션 계층 (View) + +#### 📄 AllItems.jsp (신규 생성) +```jsp +<%@ 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"%> ``` -docker compose up -d + +**화면 구성**: +- **헤더**: "All Products" 제목 +- **테이블**: 상품 목록 표시 +- **링크**: 상품 상세 페이지 이동, 장바구니 추가 +- **Back 링크**: 메인 메뉴로 돌아가기 + +--- + +### 5. 네비게이션 추가 + +#### Main.jsp - 사이드바 +```jsp + ``` -## Try integration tests +#### IncludeTop.jsp - 상단 퀵링크 +```jsp + +``` + +**접근성 향상**: +- 메인 페이지 사이드바에서 즉시 접근 +- 모든 페이지 상단의 QuickLinks에서 글로벌 접근 + +--- + +## 🔄 기능 실행 흐름 + +### 사용자 시나리오 + +``` +1. 사용자가 메인 페이지 방문 + ↓ +2. 사이드바 또는 상단 QuickLinks에서 "All" 클릭 + ↓ +3. CatalogActionBean.viewAllItems() 실행 + ↓ +4. CatalogService.getAllItems() 호출 + ↓ +5. ItemMapper.getAllItems() SQL 실행 + ↓ +6. 데이터베이스에서 ITEM과 PRODUCT 조인 쿼리 실행 + ↓ +7. 결과를 List으로 반환 + ↓ +8. AllItems.jsp에서 테이블 형태로 렌더링 + ↓ +9. 사용자가 상품 목록 확인 + ↓ +10. (선택) 상품 클릭 → 상세 페이지 이동 + 또는 "Add to Cart" 클릭 → 장바구니 추가 +``` + +--- + +## 📊 변경 파일 요약 -Perform integration tests for screen transition. +### 수정된 파일 (5개) +| 파일명 | 경로 | 변경 내용 | +|--------|------|-----------| +| ItemMapper.java | src/main/java/org/mybatis/jpetstore/mapper/ | getAllItems() 메서드 추가 | +| ItemMapper.xml | src/main/resources/org/mybatis/jpetstore/mapper/ | getAllItems SQL 쿼리 추가 | +| CatalogService.java | src/main/java/org/mybatis/jpetstore/service/ | getAllItems() 서비스 메서드 추가 | +| CatalogActionBean.java | src/main/java/org/mybatis/jpetstore/web/actions/ | viewAllItems() 액션 추가 | +| Main.jsp | src/main/webapp/WEB-INF/jsp/catalog/ | 사이드바에 All 링크 추가 | +| IncludeTop.jsp | src/main/webapp/WEB-INF/jsp/common/ | QuickLinks에 All 링크 추가 | + +### 신규 생성 파일 (1개) + +| 파일명 | 경로 | 설명 | +|--------|------|------| +| AllItems.jsp | src/main/webapp/WEB-INF/jsp/catalog/ | 전체 상품 목록 뷰 페이지 (65줄) | + +### 변경 통계 +- **총 수정 라인**: +96줄 / -0줄 +- **수정된 파일**: 6개 +- **신규 파일**: 1개 + +--- + +## ✨ 주요 기능 및 특징 + +### 1. 통합 상품 조회 +- 모든 카테고리의 상품을 한 화면에서 조회 +- FISH, DOGS, CATS, BIRDS, REPTILES 등 전 카테고리 포함 + +### 2. 직관적인 UI +- 테이블 형식의 깔끔한 레이아웃 +- Item ID, Product ID, 설명, 가격 정보 한눈에 확인 +- 기존 JPetStore 디자인과 일관성 유지 + +### 3. 편리한 접근성 +- **메인 페이지 사이드바**: 첫 화면에서 즉시 접근 +- **상단 QuickLinks**: 모든 페이지에서 글로벌 접근 가능 +- 명확한 "All" 라벨로 기능 직관적 전달 + +### 4. 상세 기능 연동 +- Item ID 클릭 → 상품 상세 페이지 이동 +- Add to Cart 버튼 → 장바구니 즉시 추가 +- Return to Main Menu 링크 → 메인 페이지 복귀 + +### 5. 가격 포맷팅 +- 통화 형식 자동 적용: `$#,##0.00` +- 예: `$10.00`, `$1,234.56` + +--- + +## 🎯 기대 효과 + +### 사용자 관점 +1. **탐색 시간 단축**: 여러 카테고리를 돌아다니지 않고 한 번에 확인 +2. **비교 편의성**: 다양한 카테고리의 상품을 쉽게 비교 +3. **구매 결정 향상**: 더 많은 선택지 제공으로 만족도 증가 + +### 비즈니스 관점 +1. **전환율 향상**: 사용자가 더 많은 상품을 탐색할 기회 제공 +2. **사용자 경험 개선**: 직관적인 UI로 이탈률 감소 +3. **확장 가능성**: 향후 필터링, 정렬, 검색 기능 추가 기반 마련 + +### 기술적 관점 +1. **유지보수성**: MVC 패턴 준수로 코드 구조 명확 +2. **재사용성**: 기존 컴포넌트 최대한 활용 +3. **확장성**: 새로운 기능 추가 용이 + +--- + +## 🔍 향후 개선 방향 + +### 단기 개선 사항 +- [ ] 페이지네이션 추가 (대량 상품 처리) +- [ ] 카테고리별 필터링 기능 +- [ ] 가격 범위 필터 +- [ ] 정렬 옵션 (가격순, 이름순 등) + +### 중기 개선 사항 +- [ ] 검색 기능 통합 +- [ ] 상품 이미지 썸네일 표시 +- [ ] Ajax 기반 동적 로딩 +- [ ] 재고 상태 표시 + +### 장기 개선 사항 +- [ ] 개인화 추천 시스템 +- [ ] 위시리스트 기능 +- [ ] 최근 본 상품 기능 +- [ ] 상품 비교 기능 + +--- + +## 📚 기술적 고려사항 + +### 성능 최적화 +- **현재**: 전체 상품 일괄 조회 +- **개선 필요**: + - 페이지네이션으로 LIMIT/OFFSET 적용 + - 인덱스 최적화 (PRODUCTID, ITEMID) + - 쿼리 결과 캐싱 (Redis, EhCache 등) + +### 보안 +- **SQL Injection**: MyBatis PreparedStatement 자동 적용으로 방지 +- **XSS 방지**: JSTL의 자동 이스케이핑 활용 + +### 확장성 +- **현재 구조**: 단일 서버 환경 +- **향후 고려**: + - 마이크로서비스 아키텍처로 전환 가능성 + - RESTful API 제공 + - React/Vue.js 프론트엔드 분리 + +--- + +## 🛠️ 테스트 시나리오 + +### 기능 테스트 +1. ✅ "All" 링크 클릭 시 전체 상품 목록 표시 +2. ✅ 상품 테이블 정상 렌더링 (Item ID, Product ID, Description, Price) +3. ✅ Item ID 클릭 시 상세 페이지 이동 +4. ✅ Add to Cart 버튼 정상 동작 +5. ✅ Return to Main Menu 링크 정상 동작 + +### 데이터 무결성 +1. ✅ ITEM과 PRODUCT 테이블 조인 정확성 +2. ✅ 모든 카테고리 상품 포함 확인 +3. ✅ 가격 포맷팅 정확성 +4. ✅ NULL 값 처리 + +### UI/UX 테스트 +1. ✅ 브라우저 호환성 (Chrome, Firefox, Safari) +2. ✅ 반응형 디자인 (추후 개선 필요) +3. ✅ 기존 디자인과의 일관성 + +--- + +## 📈 프로젝트 관리 + +### Git 커밋 히스토리 ``` -$ ./mvnw clean verify -P tomcat90 +commit 670f65b +Author: Jeong-Ryeol +Date: Mon Oct 13 17:26:54 2025 +0900 + + 모든 상품 보기 기능 추가 + + 모든 카테고리의 상품을 한 번에 볼 수 있는 기능 구현: + - ItemMapper와 CatalogService에 getAllItems() 메서드 추가 + - CatalogActionBean에 viewAllItems() 액션 추가 + - AllItems.jsp 뷰 페이지 생성 + - 사이드바와 퀵링크에 All 링크 추가 ``` + +### 브랜치 전략 +- **메인 브랜치**: `master` +- **기능 브랜치**: `feature/issue-3` + +--- + +## 💡 결론 + +### 성과 요약 +- ✅ 사용자 편의성 대폭 향상 +- ✅ 기존 시스템과의 완벽한 호환성 +- ✅ MVC 패턴 준수로 유지보수 용이 +- ✅ 확장 가능한 구조 설계 + +### 학습 포인트 +1. **MyBatis JOIN 쿼리**: 복수 테이블 조인 및 매핑 +2. **Stripes Framework**: 액션 기반 웹 프레임워크 이해 +3. **JSP/JSTL**: 동적 뷰 렌더링 및 태그 라이브러리 활용 +4. **Spring DI**: 의존성 주입 및 서비스 계층 설계 + +### 프로젝트 의의 +JPetStore의 기존 구조를 깊이 이해하고, 실제 사용자 니즈를 반영한 기능을 성공적으로 추가함으로써 **Full-Stack 개발 역량**을 입증하였습니다. + +--- + +## 📞 질의응답 + +### Q&A 예상 질문 + +**Q1: 왜 새로운 JSP를 만들었나요? 기존 페이지를 재사용할 수 없었나요?** +> A: 기존 페이지는 카테고리별 상품 조회에 특화되어 있어, 전체 상품을 표시하는 독립적인 뷰가 필요했습니다. 또한 향후 전체 상품 페이지에만 적용될 수 있는 기능(필터링, 정렬 등)을 고려하여 분리했습니다. + +**Q2: 성능 이슈는 없나요? 상품이 많아지면 어떻게 되나요?** +> A: 현재는 프로토타입 단계로 전체 조회를 구현했습니다. 향후 페이지네이션(LIMIT/OFFSET)과 쿼리 캐싱을 도입하여 대량 데이터에 대응할 계획입니다. + +**Q3: 모바일 환경은 고려했나요?** +> A: 현재는 데스크톱 환경에 최적화되어 있습니다. 향후 반응형 디자인 적용과 모바일 전용 뷰를 추가할 예정입니다. + +**Q4: 다른 기능과의 충돌은 없나요?** +> A: 기존 코드를 수정하지 않고 새로운 메서드와 페이지를 추가하는 방식으로 구현하여 기존 기능에 영향을 주지 않습니다. + +--- + +## 📚 참고 자료 + +### 프로젝트 저장소 +- GitHub: [JPetStore-6](https://github.com/mybatis/jpetstore-6) + +### 사용 기술 문서 +- [Spring Framework Documentation](https://spring.io/projects/spring-framework) +- [MyBatis Documentation](https://mybatis.org/mybatis-3/) +- [Stripes Framework](https://stripesframework.atlassian.net/wiki/spaces/STRIPES/overview) + +--- + +**발표일**: 2025년 10월 +**발표자**: 정원열 +**프로젝트**: JPetStore - 모든 상품 보기 기능 추가 \ No newline at end of file diff --git a/README.original.md b/README.original.md new file mode 100644 index 000000000..4cb6a7425 --- /dev/null +++ b/README.original.md @@ -0,0 +1,84 @@ +MyBatis JPetStore +================= + +[![Java CI](https://github.com/mybatis/jpetstore-6/actions/workflows/ci.yaml/badge.svg)](https://github.com/mybatis/jpetstore-6/actions/workflows/ci.yaml) +[![Container Support](https://github.com/mybatis/jpetstore-6/actions/workflows/support.yaml/badge.svg)](https://github.com/mybatis/jpetstore-6/actions/workflows/support.yaml) +[![Coverage Status](https://coveralls.io/repos/github/mybatis/jpetstore-6/badge.svg?branch=master)](https://coveralls.io/github/mybatis/jpetstore-6?branch=master) +[![License](https://img.shields.io/:license-apache-brightgreen.svg)](https://www.apache.org/licenses/LICENSE-2.0.html) + +![mybatis-jpetstore](https://mybatis.org/images/mybatis-logo.png) + +JPetStore 6 is a full web application built on top of MyBatis 3, Spring 5 and Stripes. + +Essentials +---------- + +* [See the docs](http://www.mybatis.org/jpetstore-6) + +## Other versions that you may want to know about + +- JPetstore on top of Spring, Spring MVC, MyBatis 3, and Spring Security https://github.com/making/spring-jpetstore +- JPetstore with Vaadin and Spring Boot with Java Config https://github.com/igor-baiborodine/jpetstore-6-vaadin-spring-boot +- JPetstore on MyBatis Spring Boot Starter https://github.com/kazuki43zoo/mybatis-spring-boot-jpetstore + +## Run on Application Server +Running JPetStore sample under Tomcat (using the [cargo-maven2-plugin](https://codehaus-cargo.github.io/cargo/Maven2+plugin.html)). + +- Clone this repository + + ``` + $ git clone https://github.com/mybatis/jpetstore-6.git + ``` + +- Build war file + + ``` + $ cd jpetstore-6 + $ ./mvnw clean package + ``` + +- Startup the Tomcat server and deploy web application + + ``` + $ ./mvnw cargo:run -P tomcat90 + ``` + + > Note: + > + > We provide maven profiles per application server as follow: + > + > | Profile | Description | + > | -------------- | ----------- | + > | tomcat90 | Running under the Tomcat 9.0 | + > | tomcat85 | Running under the Tomcat 8.5 | + > | tomee80 | Running under the TomEE 8.0(Java EE 8) | + > | tomee71 | Running under the TomEE 7.1(Java EE 7) | + > | wildfly26 | Running under the WildFly 26(Java EE 8) | + > | wildfly13 | Running under the WildFly 13(Java EE 7) | + > | liberty-ee8 | Running under the WebSphere Liberty(Java EE 8) | + > | liberty-ee7 | Running under the WebSphere Liberty(Java EE 7) | + > | jetty | Running under the Jetty 9 | + > | glassfish5 | Running under the GlassFish 5(Java EE 8) | + > | glassfish4 | Running under the GlassFish 4(Java EE 7) | + > | resin | Running under the Resin 4 | + +- Run application in browser http://localhost:8080/jpetstore/ +- Press Ctrl-C to stop the server. + +## Run on Docker +``` +docker build . -t jpetstore +docker run -p 8080:8080 jpetstore +``` +or with Docker Compose: +``` +docker compose up -d +``` + +## Try integration tests + +Perform integration tests for screen transition. + +``` +$ ./mvnw clean verify -P tomcat90 +``` 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/scripts/setup-oracle-vm.sh b/scripts/setup-oracle-vm.sh new file mode 100644 index 000000000..ba285d44e --- /dev/null +++ b/scripts/setup-oracle-vm.sh @@ -0,0 +1,127 @@ +#!/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에서 실행할 초기 설정 스크립트 +# 사용법: +# 1. Oracle Cloud VM에 SSH 접속 +# 2. 이 스크립트를 복사하여 실행 +# 3. sudo bash setup-oracle-vm.sh + +set -e + +echo "=========================================" +echo "Oracle Cloud VM 초기 설정 시작" +echo "=========================================" + +# 시스템 업데이트 +echo "📦 시스템 패키지 업데이트 중..." +sudo yum update -y + +# Java 17 설치 +echo "☕ Java 17 설치 중..." +sudo yum install -y java-17-openjdk java-17-openjdk-devel + +# Java 버전 확인 +java -version + +# Tomcat 사용자 생성 +echo "👤 Tomcat 사용자 생성 중..." +sudo useradd -r -m -U -d /opt/tomcat -s /bin/false tomcat || echo "Tomcat user already exists" + +# Tomcat 9 다운로드 및 설치 +TOMCAT_VERSION="9.0.105" +echo "🐱 Tomcat ${TOMCAT_VERSION} 다운로드 중..." + +cd /tmp +wget https://dlcdn.apache.org/tomcat/tomcat-9/v${TOMCAT_VERSION}/bin/apache-tomcat-${TOMCAT_VERSION}.tar.gz + +# Tomcat 압축 해제 +echo "📂 Tomcat 압축 해제 중..." +sudo tar xzvf apache-tomcat-${TOMCAT_VERSION}.tar.gz -C /opt/tomcat --strip-components=1 + +# 권한 설정 +echo "🔐 권한 설정 중..." +sudo chown -R tomcat:tomcat /opt/tomcat/ +sudo chmod +x /opt/tomcat/bin/*.sh + +# Systemd 서비스 파일 생성 +echo "⚙️ Systemd 서비스 설정 중..." +sudo tee /etc/systemd/system/tomcat.service > /dev/null < /dev/null + +echo "=========================================" +echo "✅ Oracle Cloud VM 설정 완료!" +echo "=========================================" +echo "" +echo "다음 단계:" +echo "1. Oracle Cloud 콘솔에서 보안 목록 설정:" +echo " - Networking > Virtual Cloud Networks" +echo " - VCN 선택 > Security Lists" +echo " - Ingress Rules 추가: 0.0.0.0/0, TCP, 8080" +echo "" +echo "2. GitHub Secrets에 다음 추가:" +echo " - ORACLE_HOST: $(curl -s ifconfig.me)" +echo " - ORACLE_USER: $(whoami)" +echo " - ORACLE_SSH_KEY: (SSH private key 내용)" +echo " - GEMINI_API_KEY: (이미 추가됨)" +echo "" +echo "3. Tomcat 상태 확인:" +echo " sudo systemctl status tomcat" +echo "" +echo "4. 로그 확인:" +echo " sudo tail -f /opt/tomcat/logs/catalina.out" \ No newline at end of file diff --git a/scripts/setup-ubuntu-vm.sh b/scripts/setup-ubuntu-vm.sh new file mode 100644 index 000000000..d33681008 --- /dev/null +++ b/scripts/setup-ubuntu-vm.sh @@ -0,0 +1,121 @@ +#!/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에서 실행할 초기 설정 스크립트 +# 사용법: +# 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" diff --git a/src/main/java/org/mybatis/jpetstore/domain/Account.java b/src/main/java/org/mybatis/jpetstore/domain/Account.java index eb87ca17d..4b57354bf 100644 --- a/src/main/java/org/mybatis/jpetstore/domain/Account.java +++ b/src/main/java/org/mybatis/jpetstore/domain/Account.java @@ -1,5 +1,5 @@ /* - * Copyright 2010-2022 the original author or authors. + * 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. @@ -22,7 +22,7 @@ /** * The Class Account. * - * @author Eduardo Macarron + * @author Eduardo Macarron # Add comment for test coderabbit 코드레빗 pr 테스트를 위해 추가되는 구문입니다. */ public class Account implements Serializable { 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/mapper/ItemMapper.java b/src/main/java/org/mybatis/jpetstore/mapper/ItemMapper.java index a0c42d952..c8fffee1f 100644 --- a/src/main/java/org/mybatis/jpetstore/mapper/ItemMapper.java +++ b/src/main/java/org/mybatis/jpetstore/mapper/ItemMapper.java @@ -1,5 +1,5 @@ /* - * Copyright 2010-2022 the original author or authors. + * 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. @@ -35,4 +35,6 @@ public interface ItemMapper { Item getItem(String itemId); + List getAllItems(); + } diff --git a/src/main/java/org/mybatis/jpetstore/service/AIRecommendationService.java b/src/main/java/org/mybatis/jpetstore/service/AIRecommendationService.java new file mode 100644 index 000000000..a22b357d3 --- /dev/null +++ b/src/main/java/org/mybatis/jpetstore/service/AIRecommendationService.java @@ -0,0 +1,287 @@ +/* + * 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. + */ +package org.mybatis.jpetstore.service; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.ArrayList; +import java.util.List; + +import org.mybatis.jpetstore.domain.Item; +import org.mybatis.jpetstore.domain.Product; +import org.springframework.stereotype.Service; + +/** + * AI-based product recommendation service using Google Gemini API. + * + * @author JPetStore Team + */ +@Service +public class AIRecommendationService { + + private static final String GEMINI_API_URL = "https://generativelanguage.googleapis.com/v1/models/gemini-2.0-flash:generateContent"; + private static final String API_KEY_ENV = "GEMINI_API_KEY"; + + private final HttpClient httpClient; + private final String apiKey; + private final CatalogService catalogService; + + public AIRecommendationService(CatalogService catalogService) { + this.httpClient = HttpClient.newHttpClient(); + this.apiKey = System.getenv(API_KEY_ENV); + this.catalogService = catalogService; + } + + /** + * Get AI-powered product recommendations based on the current item. + * + * @param item + * the current item being viewed + * + * @return list of recommendation strings + */ + public List getRecommendations(Item item) { + // API 키가 설정되지 않은 경우 데모 모드 + if (apiKey == null || apiKey.isEmpty()) { + return getDemoRecommendations(item); + } + + try { + String prompt = buildPrompt(item); + String requestBody = buildRequestBody(prompt); + + HttpRequest request = HttpRequest.newBuilder().uri(URI.create(GEMINI_API_URL + "?key=" + apiKey)) + .header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString(requestBody)).build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() == 200) { + return parseResponse(response.body()); + } else { + System.err.println("Gemini API Error: " + response.statusCode() + " - " + response.body()); + return getDemoRecommendations(item); + } + + } catch (Exception e) { + System.err.println("Error calling Gemini API: " + e.getMessage()); + e.printStackTrace(); + return getDemoRecommendations(item); + } + } + + /** + * 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(); + String categoryId = item.getProduct().getCategoryId(); + String description = item.getProduct().getDescription(); + 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(); + + // 데이터베이스에서 같은 카테고리의 다른 제품들 조회 + 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); + } + + /** + * Build JSON request body for Gemini API. + */ + private String buildRequestBody(String prompt) { + // JSON 문자열 이스케이프 처리 + String escapedPrompt = prompt.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r") + .replace("\t", "\\t"); + + return String.format("{\"contents\":[{\"parts\":[{\"text\":\"%s\"}]}]}", escapedPrompt); + } + + /** + * Parse the Gemini API response and extract recommendations with item links. + */ + private List parseResponse(String responseBody) { + List recommendations = new ArrayList<>(); + + try { + // 간단한 JSON 파싱 (라이브러리 없이) + // "text": "내용" 패턴을 찾음 + int textStart = responseBody.indexOf("\"text\": \""); + if (textStart != -1) { + textStart += 9; // "text": " 길이 + int textEnd = responseBody.indexOf("\"", textStart); + + if (textEnd != -1) { + String content = responseBody.substring(textStart, textEnd); + // 이스케이프 문자 복원 + content = content.replace("\\n", "\n").replace("\\\"", "\"").replace("\\\\", "\\"); + + // 줄바꿈으로 분리하여 추천 항목 추출 + String[] lines = content.split("\n"); + for (String line : lines) { + line = line.trim(); + if (!line.isEmpty() && (line.matches("^\\d+\\..*") || line.startsWith("-"))) { + // [아이템ID] 패턴을 찾아서 링크로 변환 + String processedLine = convertItemIdToLink(line); + recommendations.add(processedLine); + } + } + } + } + + // 파싱 실패 시 원본 반환 + if (recommendations.isEmpty()) { + recommendations.add("AI 추천을 처리하는 중 문제가 발생했습니다."); + } + + } catch (Exception e) { + System.err.println("Error parsing Gemini response: " + e.getMessage()); + recommendations.add("AI 추천을 처리하는 중 문제가 발생했습니다."); + } + + return recommendations; + } + + /** + * 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": + return "함께 키우기 좋은 어종입니다"; + case "DOGS": + return "반려견에게 필요한 제품입니다"; + case "CATS": + return "고양이가 좋아할 제품입니다"; + case "BIRDS": + return "새의 건강에 도움이 됩니다"; + case "REPTILES": + return "파충류 사육에 유용합니다"; + default: + return "함께 구매하면 좋은 제품입니다"; + } + } + + /** + * Null-safe string utility. + */ + private String nullSafe(String str) { + return str != null ? str : ""; + } +} diff --git a/src/main/java/org/mybatis/jpetstore/service/CatalogService.java b/src/main/java/org/mybatis/jpetstore/service/CatalogService.java index 1af097edd..48473a225 100644 --- a/src/main/java/org/mybatis/jpetstore/service/CatalogService.java +++ b/src/main/java/org/mybatis/jpetstore/service/CatalogService.java @@ -1,5 +1,5 @@ /* - * Copyright 2010-2023 the original author or authors. + * 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. @@ -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/service/ChatbotService.java b/src/main/java/org/mybatis/jpetstore/service/ChatbotService.java new file mode 100644 index 000000000..da0bf9066 --- /dev/null +++ b/src/main/java/org/mybatis/jpetstore/service/ChatbotService.java @@ -0,0 +1,193 @@ +/* + * 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. + */ +package org.mybatis.jpetstore.service; + +import java.net.URI; +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; + +/** + * AI-powered chatbot service using Google Gemini API. + * + * @author JPetStore Team + */ +@Service +public class ChatbotService { + + private static final String GEMINI_API_URL = "https://generativelanguage.googleapis.com/v1/models/gemini-2.0-flash:generateContent"; + private static final String API_KEY_ENV = "GEMINI_API_KEY"; + + private final HttpClient httpClient; + private final String apiKey; + private final CatalogService catalogService; + + public ChatbotService(CatalogService catalogService) { + this.httpClient = HttpClient.newHttpClient(); + this.apiKey = System.getenv(API_KEY_ENV); + this.catalogService = catalogService; + } + + /** + * Get AI chatbot response for user question. + * + * @param userMessage + * the user's question + * + * @return AI response string + */ + public String getChatResponse(String userMessage) { + // API 키가 설정되지 않은 경우 데모 모드 + if (apiKey == null || apiKey.isEmpty()) { + return getDemoResponse(userMessage); + } + + try { + String prompt = buildChatPrompt(userMessage); + String requestBody = buildRequestBody(prompt); + + HttpRequest request = HttpRequest.newBuilder().uri(URI.create(GEMINI_API_URL + "?key=" + apiKey)) + .header("Content-Type", "application/json").POST(HttpRequest.BodyPublishers.ofString(requestBody)).build(); + + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() == 200) { + return parseResponse(response.body()); + } else { + System.err.println("Gemini API Error: " + response.statusCode() + " - " + response.body()); + return getDemoResponse(userMessage); + } + + } catch (Exception e) { + System.err.println("Error calling Gemini API: " + e.getMessage()); + e.printStackTrace(); + return getDemoResponse(userMessage); + } + } + + /** + * 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" + + "**중요**: 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); + } + + /** + * Build JSON request body for Gemini API. + */ + private String buildRequestBody(String prompt) { + String escapedPrompt = prompt.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r") + .replace("\t", "\\t"); + + return String.format("{\"contents\":[{\"parts\":[{\"text\":\"%s\"}]}]}", escapedPrompt); + } + + /** + * Parse the Gemini API response. + */ + private String parseResponse(String responseBody) { + try { + int textStart = responseBody.indexOf("\"text\": \""); + if (textStart != -1) { + textStart += 9; + int textEnd = responseBody.indexOf("\"", textStart); + + if (textEnd != -1) { + String content = responseBody.substring(textStart, textEnd); + content = content.replace("\\n", "\n").replace("\\\"", "\"").replace("\\\\", "\\"); + return content.trim(); + } + } + + return "죄송합니다. 응답을 처리하는 중 문제가 발생했습니다."; + + } catch (Exception e) { + System.err.println("Error parsing Gemini response: " + e.getMessage()); + return "죄송합니다. 응답을 처리하는 중 문제가 발생했습니다."; + } + } + + /** + * Provide demo responses when API is not available. + */ + private String getDemoResponse(String userMessage) { + String lowerMessage = userMessage.toLowerCase(); + + if (lowerMessage.contains("배송") || lowerMessage.contains("delivery") || lowerMessage.contains("shipping")) { + return "JPetStore는 전국 어디든 2-3일 내에 배송해드립니다. 50,000원 이상 구매 시 무료 배송 혜택을 받으실 수 있습니다!"; + } else if (lowerMessage.contains("반품") || lowerMessage.contains("교환") || lowerMessage.contains("return")) { + return "상품 수령 후 7일 이내에 반품 및 교환이 가능합니다. 단, 상품이 미개봉 상태여야 하며, 반려동물 사료의 경우 유통기한이 충분해야 합니다."; + } else if (lowerMessage.contains("추천") || lowerMessage.contains("recommend") || lowerMessage.contains("어떤") + || lowerMessage.contains("좋은")) { + return "애완동물 종류에 따라 추천 상품이 다릅니다! 물고기를 키우신다면 고급 어류 사료와 여과 시스템을, 강아지라면 프리미엄 사료와 장난감을 추천드립니다. 상품 페이지에서 AI 추천 기능도 확인해보세요!"; + } else if (lowerMessage.contains("가격") || lowerMessage.contains("price") || lowerMessage.contains("할인") + || lowerMessage.contains("discount")) { + return "JPetStore는 합리적인 가격으로 다양한 애완동물 용품을 제공합니다. 회원 가입 시 첫 구매 10% 할인 쿠폰을 드립니다!"; + } else if (lowerMessage.contains("사료") || lowerMessage.contains("food") || lowerMessage.contains("먹이")) { + return "저희는 각 동물별로 특화된 고품질 사료를 판매하고 있습니다. 영양 균형이 잘 맞춰진 프리미엄 사료부터 기본 사료까지 다양하게 준비되어 있습니다."; + } else if (lowerMessage.contains("안녕") || lowerMessage.contains("hello") || lowerMessage.contains("hi")) { + return "안녕하세요! JPetStore AI 고객 지원입니다. 무엇을 도와드릴까요? 상품 문의, 배송, 반품 등 궁금하신 점을 말씀해주세요!"; + } else { + return "JPetStore에 오신 것을 환영합니다! 물고기, 강아지, 고양이, 새, 파충류 용품을 판매하고 있습니다. 구체적으로 어떤 도움이 필요하신가요? 상품 추천, 배송, 반품 등에 대해 문의해주세요!"; + } + } +} 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/CatalogActionBean.java b/src/main/java/org/mybatis/jpetstore/web/actions/CatalogActionBean.java index a688b3c6c..401011579 100644 --- a/src/main/java/org/mybatis/jpetstore/web/actions/CatalogActionBean.java +++ b/src/main/java/org/mybatis/jpetstore/web/actions/CatalogActionBean.java @@ -1,5 +1,5 @@ /* - * Copyright 2010-2022 the original author or authors. + * 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. @@ -25,6 +25,7 @@ import org.mybatis.jpetstore.domain.Category; import org.mybatis.jpetstore.domain.Item; import org.mybatis.jpetstore.domain.Product; +import org.mybatis.jpetstore.service.AIRecommendationService; import org.mybatis.jpetstore.service.CatalogService; /** @@ -42,10 +43,14 @@ 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; + @SpringBean + private transient AIRecommendationService aiRecommendationService; + private String keyword; private String categoryId; @@ -60,6 +65,8 @@ public class CatalogActionBean extends AbstractActionBean { private Item item; private List itemList; + private List aiRecommendations; + public String getKeyword() { return keyword; } @@ -140,6 +147,14 @@ public void setItemList(List itemList) { this.itemList = itemList; } + public List getAiRecommendations() { + return aiRecommendations; + } + + public void setAiRecommendations(List aiRecommendations) { + this.aiRecommendations = aiRecommendations; + } + @DefaultHandler public ForwardResolution viewMain() { return new ForwardResolution(MAIN); @@ -179,6 +194,10 @@ public ForwardResolution viewProduct() { public ForwardResolution viewItem() { item = catalogService.getItem(itemId); product = item.getProduct(); + + // Get AI-powered recommendations + aiRecommendations = aiRecommendationService.getRecommendations(item); + return new ForwardResolution(VIEW_ITEM); } @@ -197,6 +216,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/java/org/mybatis/jpetstore/web/actions/ChatbotActionBean.java b/src/main/java/org/mybatis/jpetstore/web/actions/ChatbotActionBean.java new file mode 100644 index 000000000..cd1e44ddf --- /dev/null +++ b/src/main/java/org/mybatis/jpetstore/web/actions/ChatbotActionBean.java @@ -0,0 +1,92 @@ +/* + * 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. + */ +package org.mybatis.jpetstore.web.actions; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +import net.sourceforge.stripes.action.DefaultHandler; +import net.sourceforge.stripes.action.Resolution; +import net.sourceforge.stripes.action.StreamingResolution; +import net.sourceforge.stripes.integration.spring.SpringBean; + +import org.mybatis.jpetstore.service.ChatbotService; + +/** + * The Class ChatbotActionBean. + * + * @author JPetStore Team + */ +public class ChatbotActionBean extends AbstractActionBean { + + private static final long serialVersionUID = 1L; + + @SpringBean + private transient ChatbotService chatbotService; + + private String message; + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + /** + * Handle chatbot message and return JSON response. + * + * @return JSON response with chatbot reply + */ + @DefaultHandler + public Resolution chat() { + try { + String response = chatbotService.getChatResponse(message); + + // JSON 응답 생성 + String jsonResponse = String.format("{\"success\": true, \"response\": \"%s\"}", escapeJson(response)); + + // UTF-8 바이트 스트림으로 변환 + byte[] bytes = jsonResponse.getBytes(StandardCharsets.UTF_8); + InputStream inputStream = new ByteArrayInputStream(bytes); + + return new StreamingResolution("application/json; charset=UTF-8", inputStream); + + } catch (Exception e) { + System.err.println("Chatbot error: " + e.getMessage()); + e.printStackTrace(); + + String errorJson = "{\"success\": false, \"response\": \"죄송합니다. 일시적인 오류가 발생했습니다.\"}"; + byte[] errorBytes = errorJson.getBytes(StandardCharsets.UTF_8); + InputStream errorStream = new ByteArrayInputStream(errorBytes); + + return new StreamingResolution("application/json; charset=UTF-8", errorStream); + } + } + + /** + * Escape special characters for JSON. + */ + private String escapeJson(String str) { + if (str == null) { + return ""; + } + return str.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r").replace("\t", + "\\t"); + } +} 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/resources/org/mybatis/jpetstore/mapper/ItemMapper.xml b/src/main/resources/org/mybatis/jpetstore/mapper/ItemMapper.xml index 509f0d838..47c06199e 100644 --- a/src/main/resources/org/mybatis/jpetstore/mapper/ItemMapper.xml +++ b/src/main/resources/org/mybatis/jpetstore/mapper/ItemMapper.xml @@ -1,7 +1,7 @@ + + + + + + + + + + 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"%>
+ + + + + + + + + + + + + + + + +
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/Category.jsp b/src/main/webapp/WEB-INF/jsp/catalog/Category.jsp index b76f6feb4..212bf94a1 100644 --- a/src/main/webapp/WEB-INF/jsp/catalog/Category.jsp +++ b/src/main/webapp/WEB-INF/jsp/catalog/Category.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"%>