Agent 2.0 exdev 서버 배포 스택

- server-dev start/stop/deploy 및 Gitea push 자동 배포
- local-dev 로컬 개발 환경

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Macbook
2026-07-21 22:57:30 +09:00
commit 4b86b2a660
344 changed files with 45787 additions and 0 deletions
@@ -0,0 +1,37 @@
HELP.md
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
bin/
!**/src/main/**/bin/
!**/src/test/**/bin/
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
out/
!**/src/main/**/out/
!**/src/test/**/out/
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
### VS Code ###
.vscode/
@@ -0,0 +1,359 @@
# 카카오 챗봇 Callback 방식 구현 정리
## 📋 구현 개요
폴백 블록에서 RAG API 호출 시 5초 타임아웃 문제를 해결하기 위해 **카카오 Callback 방식**을 구현했습니다.
---
## 🔍 문제 원인
### **카카오 i 오픈빌더 타임아웃 제한**
- **스킬 서버 응답 제한**: 5초
- **RAG API 응답 시간**: 최대 7초 이상 소요
- **결과**: 타임아웃 발생으로 사용자에게 응답이 표시되지 않음
### **로그 분석 결과**
```
❌ 표시되지 않는 케이스 (타이어펑크):
18:47:28.094 - RAG API 호출 시작
18:47:35.237 - RAG API 응답 수신
→ 응답 시간: 7.14초 (5초 초과)
✅ 정상 표시되는 케이스 (주식):
18:48:29.097 - RAG API 호출 시작
18:48:29.219 - RAG API 응답 수신
→ 응답 시간: 0.12초 (5초 이내)
```
---
## 💡 해결 방법: Callback 방식
### **2단계 프로세스**
#### **1단계: 즉시 응답 (5초 내)**
```json
{
"version": "2.0",
"useCallback": true,
"template": {
"outputs": [
{
"simpleText": {
"text": "답변을 생성하고 있습니다. 잠시만 기다려주세요..."
}
}
],
"quickReplies": [ ... ]
}
}
```
- `useCallback: true` 설정으로 비동기 처리 선언
- 사용자에게 "처리중" 메시지 즉시 표시
#### **2단계: Callback 응답 (시간 제한 없음)**
```json
{
"version": "2.0",
"template": {
"outputs": [
{
"simpleText": {
"text": "최종 답변 내용..."
}
}
],
"quickReplies": [ ... ]
}
}
```
- RAG API 처리 완료 후 `callbackUrl`로 POST
- 사용자 화면의 "처리중" 메시지가 최종 답변으로 교체
---
## 🔧 구현 내용
### **1. handleFallbackWithAiServer() - 메인 메서드**
```java
@KakaoIntent("폴백 블록")
public SkillResponse handleFallbackWithAiServer(KakaoRequest req) {
// callbackUrl 추출
String callbackUrl = req.getUserRequest().getCallbackUrl();
if (callbackUrl != null && !callbackUrl.isEmpty()) {
// Callback 방식: 비동기 처리
- 즉시 "처리중" 응답 반환 (useCallback: true)
- CompletableFuture로 백그라운드 처리
return immediateResponse;
} else {
// callbackUrl 없음: 동기 처리 (5초 제한)
return processRagSync(...);
}
}
```
**특징:**
- callbackUrl 유무에 따라 자동으로 처리 방식 선택
- 관리자 모드 테스트(callbackUrl 없음) ⟶ 동기 처리
- 실제 사용자(callbackUrl 있음) ⟶ Callback 처리
---
### **2. processRagAndSendCallback() - 비동기 처리**
```java
private void processRagAndSendCallback(String userInput, String userId,
String callbackUrl, List<QuickReply> quickReplies) {
try {
// RAG API 호출 (시간 제한 없음)
String answer = callRagApi(userInput, userId);
// Callback으로 최종 답변 전송
sendCallback(callbackUrl, answer, quickReplies);
} catch (Exception e) {
// 오류 시에도 Callback으로 에러 메시지 전송
sendCallback(callbackUrl, "에러 메시지", quickReplies);
}
}
```
**실행 방식:**
- `CompletableFuture.runAsync()` 사용
- 별도 스레드에서 실행 (메인 스레드 블로킹 없음)
- 오류 발생 시에도 사용자에게 에러 메시지 전송
---
### **3. sendCallback() - Callback 전송**
```java
private void sendCallback(String callbackUrl, String answer, List<QuickReply> quickReplies) {
// Callback 응답 구성
SkillResponse callbackResponse = new SkillResponse();
callbackResponse.setVersion("2.0");
// useCallback 설정 안 함 (최종 응답)
callbackResponse.setTemplate(...);
// callbackUrl로 POST
restTemplate.postForEntity(callbackUrl, callbackResponse, String.class);
}
```
**중요:**
- `useCallback`을 설정하지 않음 (또는 false)
- 카카오가 제공한 callbackUrl로 POST
---
### **4. processRagSync() - 동기 처리 (Fallback)**
```java
private SkillResponse processRagSync(String userInput, String userId,
List<QuickReply> quickReplies) {
// 동기 방식으로 RAG API 호출 (5초 제한 있음)
String answer = callRagApi(userInput, userId);
// 즉시 응답 반환
return skillResponse.getResult(outputs, quickReplies);
}
```
**용도:**
- callbackUrl이 없을 때 (관리자 모드 테스트 등)
- 기존 방식과 동일
---
### **5. callRagApi() - RAG API 호출 (공통 메서드)**
```java
private String callRagApi(String userInput, String userId) throws Exception {
// RAG API 호출
Map<String, Object> body = new HashMap<>();
body.put("question", userInput);
body.put("botId", userId);
Map<String, Object> res = restTemplate.postForObject(
CHATBOT_API + "v1/rag-only", entity, Map.class);
// 응답 파싱 및 줄바꿈 정리
String answer = ...;
answer = answer.replaceAll(" \\n", "\n"); // 마크다운 줄바꿈
answer = answer.replaceAll("\\n{3,}", "\n\n"); // 연속 줄바꿈 제한
answer = answer.replaceAll(" *\\n *", "\n"); // 공백 제거
return answer;
}
```
**중복 코드 제거:**
- 동기/비동기 모두 이 메서드 사용
- 줄바꿈 정리 로직 포함
---
## 🚀 동작 흐름
### **Callback 방식 (실제 사용자)**
```
사용자: "타이어펑크"
↓
카카오 → 스킬서버
- callbackUrl 포함
↓
스킬서버: 즉시 응답 (0.1초)
- "답변을 생성하고 있습니다..."
- useCallback: true
↓
카카오 → 사용자
- "답변을 생성하고 있습니다..." 표시
↓
[백그라운드 처리]
스킬서버 → RAG API (7초 소요)
↓
스킬서버 → callbackUrl
- 최종 답변 POST
↓
카카오: 메시지 교체
- "답변을 생성하고 있습니다..."
→ "타이어 펑크 보상 안내..."
↓
사용자: 최종 답변 확인
```
### **동기 방식 (관리자 테스트)**
```
관리자: "주식"
↓
스킬서버: callbackUrl 없음 확인
↓
동기 방식 처리
- RAG API 호출 (0.12초)
↓
즉시 응답 반환
- "죄송합니다. 준비된 답이 없습니다..."
↓
관리자: 답변 확인
```
---
## 📊 테스트 결과
### **빌드 성공**
```bash
> .\gradlew.bat build -x test
BUILD SUCCESSFUL in 1s
5 actionable tasks: 3 executed, 2 up-to-date
```
### **컴파일 성공**
- 문법 오류 없음
- 린트 warning은 기존 코드에서 발생한 것
---
## ✅ 적용된 개선 사항
### **1. 5초 타임아웃 해결**
- ✅ Callback 방식으로 시간 제한 없음
- ✅ RAG API가 10초 걸려도 정상 처리
### **2. 사용자 경험 개선**
- ✅ "처리중" 메시지로 대기 안내
- ✅ 답변 생성 완료 시 자동 업데이트
### **3. 안정성 향상**
- ✅ callbackUrl 없을 때 동기 처리로 자동 폴백
- ✅ 오류 시에도 Callback으로 에러 메시지 전송
### **4. 코드 구조 개선**
- ✅ RAG API 호출 로직 공통 메서드로 분리
- ✅ 비동기/동기 처리 명확히 구분
- ✅ 상세한 로그로 디버깅 용이
### **5. 줄바꿈 호환성**
- ✅ 마크다운 줄바꿈(` \n`) 처리
- ✅ 연속 줄바꿈 정리
- ✅ 카카오톡 SimpleText 호환
---
## 📝 테스트 방법
### **1. 실제 카카오톡에서 테스트**
```
사용자: "타이어펑크나면"
예상 결과:
1. "답변을 생성하고 있습니다..." 즉시 표시
2. 몇 초 후 실제 답변으로 업데이트
```
### **2. 로그 확인**
```
✅ callbackUrl이 출력되는지 확인:
callbackUrl: https://kakaoi-chatbot-prod.kakao.com/callback/xxxxx
✅ Callback 전송 성공 확인:
Callback 전송 완료. 상태: 200 OK
```
### **3. 관리자 모드 테스트**
```
- callbackUrl 없음 확인
- 동기 처리로 진행 확인
- 5초 이내 답변만 정상 처리
```
---
## ⚠️ 주의사항
1. **callbackUrl은 실제 환경에서만 제공**
- 관리자 모드 테스트: callbackUrl 없음 (동기 처리)
- 실제 사용자: callbackUrl 제공 (Callback 처리)
2. **callbackUrl 유효 기간**
- 보통 1분 정도
- 시간 내에 Callback 전송 필요
3. **Callback 응답 형식**
- `useCallback` 설정하지 않음
- 일반 SkillResponse와 동일한 형식
4. **비동기 처리 예외**
- 오류 발생 시에도 반드시 Callback 전송
- 사용자에게 적절한 에러 메시지 제공
---
## 📚 참고 문서
- **카카오 i 오픈빌더 공식 문서**: https://kakaobusiness.gitbook.io/main/tool/chatbot/skill_guide/make_skill
- **스킬 응답 시간 제한**: 5초
- **Callback 사용법**: useCallback 옵션 및 비동기 응답 처리
---
## 🎯 결론
**5초 타임아웃 문제를 Callback 방식으로 완벽히 해결했습니다!**
✅ RAG API 응답 시간에 관계없이 안정적으로 동작
✅ 사용자 경험 개선 (처리중 메시지 → 최종 답변)
✅ callbackUrl 유무에 따라 자동으로 최적 처리 방식 선택
✅ 코드 구조 개선 및 유지보수성 향상
---
**작성일**: 2025-01-06
**구현 완료**: ✅ 빌드 성공, 테스트 준비 완료
@@ -0,0 +1,115 @@
# kakaoChatbotSkill — 카카오/웹 진입(Web) 서비스
한국도로공사 챗봇의 **진입 계층(Web)** 입니다. 카카오 오픈빌더 웹훅과 웹앱(chatbotApp)의 요청을 받아 **Agent(RAG `/agent/chat`)** 로 위임하고, 응답을 카카오/웹 UI로 조립합니다.
> **운영:** `128.200.100.61:8083` (DMZ 웹 서버, nginx 뒤)
> **운영 모드:** `agent.mode=agent` — WAS·RAG는 모드 분기 없이 Agent tool API와 `/agent/chat`를 제공합니다.
> **전체 구조:** [`../SYSTEM_ARCHITECTURE.md`](../SYSTEM_ARCHITECTURE.md) · **Agent 상세:** [`../docs/AGENT_ARCHITECTURE.md`](../docs/AGENT_ARCHITECTURE.md)
> **콜백 구현:** [`CALLBACK_구현_정리.md`](CALLBACK_구현_정리.md)
> **최종 갱신:** 2026-07-10
---
## 1. 역할
| 책임 | 설명 |
|------|------|
| 카카오 웹훅 수신 | `/kakao/skill` (폴백 블록 발화) |
| 웹앱 수신 | `/web/ask`(JSON), `/web/ask/stream`(SSE) — chatbotApp |
| 오케스트레이션 위임 | RAG `/agent/chat` 호출 |
| WEB domain tool 제공 | `/internal/tools/data-portal` (WAS 역호출 전용, `GASSTATION`·`BRAND_SHOP`·`REST_AREA_FOOD_LIST`) |
| UI 조립 | Agent 응답 → SimpleText·ListCard·BasicCard·QuickReply·fareBreakdown |
**핵심 원칙:** 정책(`fetchOwner`/`uiType`/`needsClarification`)은 WAS+YAML이 결정하고, Skill은 **진입·위임·UI 표현**만 담당합니다.
---
## 2. 요청 흐름
```mermaid
flowchart TD
K[카카오 오픈빌더] -->|/kakao/skill| SC[SkillController]
APP[chatbotApp/브라우저] -->|/web/ask, /web/ask/stream| WC[WebChatController]
SC --> RB[ResultBuilder → KakaoIntentApp]
WC --> WS[WebChatService] --> RB
RB --> RAG[RagAgentClient → RAG :28012 /agent/chat]
ITC[InternalToolController /internal/tools/data-portal] --> IDP[InternalDataPortalToolService → data.ex.co.kr]
WAS2[WAS tool execute, agent WEB tool] -->|역호출| ITC
RAG --> UI[buildLlmAgentOutputs + SkillResponseWebExtractor]
UI --> OUT([카카오 SimpleText/카드 · WebAskResponse])
```
- **Agent 모드:** Skill → RAG `/agent/chat` 1회. WEB domain 데이터는 WAS가 `/internal/tools/data-portal`로 **역호출**해 가져옵니다.
---
## 3. 진입 API
| Method | URL | 구현 | 용도 |
|--------|-----|------|------|
| `POST`/`GET` | `/kakao/skill` (`/oakak/skill` 별칭) | `SkillController` | 카카오 폴백 블록 |
| `POST` | `/web/ask` | `WebChatController` → `WebChatService` | 웹 JSON 응답 (`WebAskResponse`) |
| `POST` | `/web/ask/stream` | `WebChatController` | SSE 스트리밍 (chatbotApp) |
| `POST` | `/internal/tools/data-portal` | `InternalToolController` | WAS→Skill WEB tool (내부 전용, `X-Internal-Tool-Key`) |
---
## 4. 주요 구현 파일
| 파일 | 책임 |
|------|------|
| `controller/SkillController.java` | 카카오 웹훅 진입 |
| `response/build/ResultBuilder.java` | intent → 핸들러 라우팅 |
| `response/build/KakaoIntentApp.java` | 카카오 fallback 블록, Agent 호출, 카드 UI 조립, `buildLlmAgentOutputs` |
| `service/RagAgentClient.java` | RAG `POST /agent/chat` 클라이언트 |
| `web/WebChatController.java`·`WebChatService.java` | `/web/ask`(+stream) → Agent 호출 및 웹 응답 매핑 |
| `web/SkillResponseWebExtractor.java` | SkillResponse → 웹 DTO(ListCard·quickReplies·fareBreakdown) |
| `api/InternalToolController.java` | WAS→Skill internal API |
| `service/InternalDataPortalToolService.java`·`DataPortalService.java` | WEB tool data.ex.co.kr 조회 |
| `config/AgentProperties.java` | `agent.mode`, `fallback-legacy`, `agent.chat.url` |
| `config/InternalToolProperties.java` | `internal.tool.api-key` |
---
## 5. 설정 (jar 옆 `application.yml`)
```yaml
agent:
mode: agent
fallback-legacy: true # 장애 대응용 fallback 설정
chat:
url: http://172.16.180.130:28012/agent/chat
timeout-ms: 100000
internal:
tool:
api-key: ${INTERNAL_TOOL_API_KEY} # WAS↔Skill 내부 인증(3서비스 동일)
```
**방화벽:** `172.16.180.130 → 128.200.100.61:8083` (WAS→Skill WEB tool), `128.200.100.61 → 172.16.180.130:28012`(Skill→RAG)·`:8086`(Skill→WAS).
---
## 6. botId · 세션 정책
| 채널 | botId | 이력·pending |
|------|-------|--------------|
| 카카오 | `userRequest.user.id` (사용자별 고정) | 24h 내 동일 사용자 이력·pending 이어짐 |
| 웹(chatbotApp) | `sessionStorage` 랜덤(`web-...`) — **대화마다 새 botId(무상태)** | 새 대화는 이력·pending 없음 |
- 카카오↔웹은 세션을 **연계하지 않고**, 웹은 대화마다 새 botId로 시작해 **과거 대화를 복원하지 않습니다**. 상세: [`../SYSTEM_ARCHITECTURE.md`](../SYSTEM_ARCHITECTURE.md).
- pending(되묻기)은 Agent 모드에서 **RAG(MongoDB `agent_pending`)** 가 소유하며 Skill은 무상태입니다. Skill은 `query + botId`만 RAG로 전달합니다.
---
## 7. 관련 문서
| 문서 | 내용 |
|------|------|
| [`../SYSTEM_ARCHITECTURE.md`](../SYSTEM_ARCHITECTURE.md) | 전체 구조·API·포트·세션 정책 |
| [`../docs/AGENT_ARCHITECTURE.md`](../docs/AGENT_ARCHITECTURE.md) | Agent 모드 상세·방화벽 |
| [`CALLBACK_구현_정리.md`](CALLBACK_구현_정리.md) | 카카오 콜백(5초 초과 응답) 구현 |
| [`../chatbotApp/README.md`](../chatbotApp/README.md) | 웹앱 UI·`/web/ask` 연동 |
@@ -0,0 +1,43 @@
plugins {
id 'java'
id 'org.springframework.boot' version '2.7.10'
id 'io.spring.dependency-management' version '1.0.15.RELEASE'
}
group = 'com.ex.chatbot'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(17)
}
sourceCompatibility = '1.8'
targetCompatibility = '1.8'
}
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.apache.httpcomponents:httpclient:4.5.13'
implementation 'org.reflections:reflections:0.9.12'
implementation 'com.google.code.gson:gson:2.8.9'
implementation 'org.jsoup:jsoup:1.14.2'
implementation 'commons-io:commons-io:2.8.0'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
tasks.named('test') {
useJUnitPlatform()
}
@@ -0,0 +1,15 @@
# kakaoChatbotSkill — 웹 서버 기동 시 export (Spring Boot는 .env 자동 로드 안 함)
# 사용: set -a && source env.example && set +a && ./start.sh start
# WAS 서버
CHATBOT_API_BASE_URL=http://172.16.180.130:8086/api/
# RAG Agent (WAS 서버와 동일 호스트)
RAG_AGENT_CHAT_URL=http://172.16.180.130:28012/agent/chat
AGENT_CHAT_TIMEOUT_MS=100000
# Agent 전환 시: legacy | agent
AGENT_MODE=legacy
# WAS/RAG ↔ Skill internal tool 공통 키 (운영에서 반드시 설정)
# INTERNAL_TOOL_API_KEY=
@@ -0,0 +1 @@
org.gradle.java.home=C:\\Program Files\\Eclipse Adoptium\\jdk-17.0.16.8-hotspot
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+241
View File
@@ -0,0 +1,241 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line,
# like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
APP_NAME="Gradle"
APP_BASE_NAME=${0##*/}
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+91
View File
@@ -0,0 +1,91 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
@@ -0,0 +1,344 @@
#user nobody;
worker_processes auto;
#error_log logs/error.log;
#error_log logs/error.log notice;
#error_log logs/error.log info;
#pid logs/nginx.pid;
events {
worker_connections 2048;
}
http {
include mime.types;
default_type application/octet-stream;
server_tokens off;
#log_format main '$remote_addr - $remote_user [$time_local] "$request" '
# '$status $body_bytes_sent "$http_referer" '
# '"$http_user_agent" "$http_x_forwarded_for"';
#access_log logs/access.log main;
sendfile on;
#tcp_nopush on;
#keepalive_timeout 0;
keepalive_timeout 65;
gzip on;
gzip_static on;
gzip_types text/plain text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript;
gzip_proxied any;
gzip_vary on;
gzip_comp_level 6;
gzip_buffers 16 8k;
gzip_http_version 1.1;
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name chatbot.ex.co.kr;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name chatbot.ex.co.kr;
ssl_certificate /home/song/exWebChat/chained.ex.co.kr.pem;
ssl_certificate_key /home/song/exWebChat/prv_ast.ex.co.kr.key;
ssl_dhparam /home/song/letsencrypt/dhparam.pem;
ssl_protocols TLSv1.2;
location / {
#root html;
#index index.html;
add_header 'Access-Control-Allow-Origin' 'https://www.extoll.co.kr';
proxy_pass http://localhost:8080;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
}
# =========================================================================
# chatbotApp 정적 UI
# URL: https://chatbot.ex.co.kr/chatbot/app/
# 파일 위치: /home/song/chatbotApp/public/
# =========================================================================
location = /chatbot/app {
return 301 /chatbot/app/;
}
location = /chatbot/app/ {
alias /home/song/chatbotApp/public/;
index index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
location = /chatbot/app/index.html {
alias /home/song/chatbotApp/public/index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
location ^~ /chatbot/app/_next/ {
alias /home/song/chatbotApp/public/_next/;
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location ^~ /chatbot/app/ {
alias /home/song/chatbotApp/public/;
try_files $uri =404;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# =========================================================================
# chatbotApp AI UI
# URL: https://chatbot.ex.co.kr/chatbot/ai/
# 파일 위치: /home/song/chatbotApp/public-ai/
# =========================================================================
location = /chatbot/ai {
return 301 /chatbot/ai/;
}
location = /chatbot/ai/ {
alias /home/song/chatbotApp/public-ai/;
index index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
location = /chatbot/ai/index.html {
alias /home/song/chatbotApp/public-ai/index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
location ^~ /chatbot/ai/_next/ {
alias /home/song/chatbotApp/public-ai/_next/;
try_files $uri =404;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location ^~ /chatbot/ai/ {
alias /home/song/chatbotApp/public-ai/;
try_files $uri =404;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# =========================================================================
# chatbotApp → Skill 웹 채팅 API
# chatbotApp 기본 API root: /chatbot
# /chatbot/ask → Skill /web/ask
# /chatbot/ask/stream → Skill /web/ask/stream
# /chatbot/openapi.json → Skill /web/openapi.json
# =========================================================================
location = /chatbot/ask {
proxy_pass http://localhost:8083/web/ask;
proxy_http_version 1.1;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $http_host;
proxy_connect_timeout 10s;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
proxy_buffering off;
}
location = /chatbot/ask/stream {
proxy_pass http://localhost:8083/web/ask/stream;
proxy_http_version 1.1;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $http_host;
# SSE 설정
proxy_connect_timeout 10s;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
proxy_buffering off;
proxy_cache off;
gzip off;
add_header X-Accel-Buffering no;
}
location = /chatbot/openapi.json {
proxy_pass http://localhost:8083/web/openapi.json;
proxy_http_version 1.1;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $http_host;
proxy_connect_timeout 10s;
proxy_read_timeout 30s;
proxy_send_timeout 30s;
}
# =========================================================================
# chatbotApp FAQ / Session / Health → RAG API
# FAQ 버튼, 세션 추적, 헬스체크용
# RAG: 172.16.180.130:28012
# =========================================================================
location ^~ /chatbot/faq/ {
proxy_pass http://172.16.180.130:28012/chatbot/faq/;
proxy_http_version 1.1;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $http_host;
proxy_connect_timeout 10s;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
proxy_buffering off;
}
location ^~ /chatbot/session/ {
proxy_pass http://172.16.180.130:28012/chatbot/session/;
proxy_http_version 1.1;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $http_host;
proxy_connect_timeout 10s;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
proxy_buffering off;
}
location = /chatbot/health {
proxy_pass http://172.16.180.130:28012/chatbot/health;
proxy_http_version 1.1;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Host $http_host;
proxy_connect_timeout 10s;
proxy_read_timeout 30s;
proxy_send_timeout 30s;
}
location /kakao {
#root html;
#index index.html;
proxy_pass http://localhost:8081;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
}
location /oakak {
#root html;
#index index.html;
proxy_pass http://localhost:8082;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
}
location /extoll {
#root html;
#index index.html;
proxy_pass http://localhost:8084;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
}
location /test/ {
#root html;
#index index.html;
proxy_pass http://localhost:8083/oakak/;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
}
location /images {
alias /usr/local/nginx/data/images;
}
location /fonts {
alias /usr/local/nginx/data/fonts;
}
location /card {
alias /usr/local/nginx/data/card;
}
#location /.well-known {
# root html/well-known;
#}
# redirect server error pages to the static page /50x.html
#
error_page 404 500 502 503 504 /error.html;
location /error.html {
root /usr/local/nginx/html/errors/;
internal;
}
}
# another virtual host using mix of IP-, name-, and port-based configuration
#
#server {
# listen 8000;
# listen somename:8080;
# server_name somename alias another.alias;
# location / {
# root html;
# index index.html index.htm;
# }
#}
# HTTPS server
#
#server {
# listen 443 ssl;
# server_name localhost;
# ssl_certificate cert.pem;
# ssl_certificate_key cert.key;
# ssl_session_cache shared:SSL:1m;
# ssl_session_timeout 5m;
# ssl_ciphers HIGH:!aNULL:!MD5;
# ssl_prefer_server_ciphers on;
# location / {
# root html;
# index index.html index.htm;
# }
#}
}
@@ -0,0 +1 @@
rootProject.name = 'kakao'
@@ -0,0 +1,12 @@
package com.ex.chatbot.kakao;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class KakaoApplication {
public static void main(String[] args) {
SpringApplication.run(KakaoApplication.class, args);
}
}
@@ -0,0 +1,65 @@
package com.ex.chatbot.kakao.api;
import com.ex.chatbot.kakao.api.dto.DataPortalToolRequest;
import com.ex.chatbot.kakao.config.InternalToolProperties;
import com.ex.chatbot.kakao.service.InternalDataPortalToolService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.Map;
/**
* chatbotApi Agent tool execution용 내부 API.
*/
@Slf4j
@RestController
@RequestMapping("/internal/tools")
@RequiredArgsConstructor
public class InternalToolController {
private final InternalDataPortalToolService internalDataPortalToolService;
private final InternalToolProperties internalToolProperties;
@PostMapping("/data-portal")
public ResponseEntity<Map<String, Object>> fetchDataPortal(
@RequestHeader(value = "X-Internal-Tool-Key", required = false) String apiKey,
@RequestBody DataPortalToolRequest request
) {
ResponseEntity<Map<String, Object>> authError = authorize(apiKey);
if (authError != null) {
return authError;
}
String intentType = request != null ? request.getIntentType() : null;
Map<String, Object> params = request != null && request.getParams() != null
? request.getParams()
: new HashMap<>();
String userInput = request != null ? request.getUserInput() : null;
log.info("[InternalTool] data-portal - intentType: {}, params: {}", intentType, params);
Map<String, Object> result = internalDataPortalToolService.fetch(intentType, params, userInput);
return ResponseEntity.ok(result);
}
private ResponseEntity<Map<String, Object>> authorize(String apiKey) {
String expected = internalToolProperties.getApiKey();
if (expected == null || expected.isBlank()) {
return null;
}
if (expected.equals(apiKey)) {
return null;
}
Map<String, Object> body = new HashMap<>();
body.put("status", false);
body.put("statusMsg", "invalid or missing X-Internal-Tool-Key");
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(body);
}
}
@@ -0,0 +1,13 @@
package com.ex.chatbot.kakao.api.dto;
import lombok.Data;
import java.util.HashMap;
import java.util.Map;
@Data
public class DataPortalToolRequest {
private String intentType;
private Map<String, Object> params = new HashMap<>();
private String userInput;
}
@@ -0,0 +1,33 @@
package com.ex.chatbot.kakao.config;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Getter
@Setter
@Configuration
@ConfigurationProperties(prefix = "agent")
public class AgentProperties {
/**
* legacy | agent — RAG /agent/chat vs WAS /llm-agent
*/
private String mode = "legacy";
private boolean fallbackLegacy = true;
private Chat chat = new Chat();
public boolean isAgentMode() {
return "agent".equalsIgnoreCase(mode);
}
@Getter
@Setter
public static class Chat {
private String url = "http://172.16.180.130:28012/agent/chat";
private int timeoutMs = 100_000;
}
}
@@ -0,0 +1,14 @@
package com.ex.chatbot.kakao.config;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Getter
@Setter
@Configuration
@ConfigurationProperties(prefix = "internal.tool")
public class InternalToolProperties {
private String apiKey = "";
}
@@ -0,0 +1,53 @@
package com.ex.chatbot.kakao.config;
import org.apache.http.client.HttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.web.client.RestTemplate;
@Configuration
public class RestfulConfig {
@Value("${restTemplate.factory.readTimeout}")
private int READ_TIMEOUT;
@Value("${restTemplate.factory.connectTimeout}")
private int CONNECT_TIMEOUT;
@Value("${restTemplate.httpClient.maxConnTotal}")
private int MAX_CONN_TOTAL;
@Value("${restTemplate.httpClient.maxConnPerRoute}")
private int MAX_CONN_PER_ROUTE;
@Value("${agent.chat.timeout-ms:100000}")
private int AGENT_READ_TIMEOUT;
@Bean
@Primary
public RestTemplate restTemplate() {
return buildRestTemplate(READ_TIMEOUT);
}
@Bean(name = "agentRestTemplate")
public RestTemplate agentRestTemplate() {
return buildRestTemplate(AGENT_READ_TIMEOUT);
}
private RestTemplate buildRestTemplate(int readTimeout) {
HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory();
factory.setReadTimeout(readTimeout);
factory.setConnectTimeout(CONNECT_TIMEOUT);
HttpClient httpClient = HttpClientBuilder.create()
.setMaxConnTotal(MAX_CONN_TOTAL)
.setMaxConnPerRoute(MAX_CONN_PER_ROUTE)
.build();
factory.setHttpClient(httpClient);
return new RestTemplate(factory);
}
}
@@ -0,0 +1,67 @@
package com.ex.chatbot.kakao.controller;
import com.ex.chatbot.kakao.skillRequest.KakaoRequest;
import com.ex.chatbot.kakao.response.build.ResultBuilder;
import com.ex.chatbot.kakao.response.element.SkillResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.HttpServletRequest;
@RestController
public class SkillController {
private static final Logger LOGGER = LoggerFactory.getLogger(SkillController.class);
@Autowired
ResultBuilder resultBuilder;
@RequestMapping(value = { "/oakak/skill", "/kakao/skill" }, method = { RequestMethod.POST,
RequestMethod.GET }, headers = { "Accept=application/json" })
public @ResponseBody SkillResponse skillRequest(@RequestBody KakaoRequest req, HttpServletRequest request) {
SkillResponse res = null;
try {
// ===== 카카오 요청 IP 및 헤더 정보 로깅 =====
String xForwardedFor = request.getHeader("X-Forwarded-For");
String xRealIp = request.getHeader("X-Real-IP");
String remoteAddr = request.getRemoteAddr();
// 실제 클라이언트 IP 추출
String clientIp = null;
if (xForwardedFor != null && !xForwardedFor.isEmpty()) {
// X-Forwarded-For의 첫 번째 IP가 실제 클라이언트 IP
int idx = xForwardedFor.indexOf(',');
clientIp = (idx > 0) ? xForwardedFor.substring(0, idx).trim() : xForwardedFor.trim();
} else if (xRealIp != null && !xRealIp.isEmpty()) {
clientIp = xRealIp;
} else {
clientIp = remoteAddr;
}
LOGGER.info("========================================");
LOGGER.info("=== Kakao Bot Request Info ===");
LOGGER.info("Client IP (Detected): {}", clientIp);
LOGGER.info("X-Forwarded-For: {}", xForwardedFor);
LOGGER.info("X-Real-IP: {}", xRealIp);
LOGGER.info("Remote Address: {}", remoteAddr);
LOGGER.info("Request URI: {}", request.getRequestURI());
LOGGER.info("Request Method: {}", request.getMethod());
LOGGER.info("User-Agent: {}", request.getHeader("User-Agent"));
LOGGER.info("X-Request-ID: {}", request.getHeader("X-Request-ID"));
LOGGER.info("KakaoI-Instance: {}", request.getHeader("KakaoI-Instance"));
LOGGER.info("========================================");
// ============================================
String intent = req.getIntent().getName();
LOGGER.info("Intent: {}", intent);
res = (SkillResponse) resultBuilder.getResult(intent, req);
} catch (Exception e) {
LOGGER.error("SkillResponse error occured : {}", e.getMessage());
e.printStackTrace();
}
return res;
}
}
@@ -0,0 +1,12 @@
package com.ex.chatbot.kakao.response.build;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface KakaoIntent {
String[] value();
}
@@ -0,0 +1,61 @@
package com.ex.chatbot.kakao.response.build;
import com.ex.chatbot.kakao.skillRequest.KakaoRequest;
import org.reflections.Reflections;
import org.reflections.scanners.MethodAnnotationsScanner;
import org.reflections.util.ClasspathHelper;
import org.reflections.util.ConfigurationBuilder;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import javax.annotation.PostConstruct;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
@Service
public class ResultBuilder {
private static final Logger LOGGER = LoggerFactory.getLogger(ResultBuilder.class);
private Map<String, Method> kakaoMethod = new HashMap<>();
@Autowired
KakaoIntentApp kakaoIntentApp;
@PostConstruct
public void getMeThodIntent() /*getIntent */ {
Reflections reflections = new Reflections(new ConfigurationBuilder()
.setUrls(ClasspathHelper.forPackage("com.ex.chatbot.kakao")).setScanners(new MethodAnnotationsScanner()));
Set<Method> methods = reflections.getMethodsAnnotatedWith(KakaoIntent.class);
try {
methods.forEach(method -> {
KakaoIntent commandMappingAnnotation = method.getAnnotation(KakaoIntent.class);
String[] keys = commandMappingAnnotation.value();
for(String key : keys){
kakaoMethod.put(key, method);
}
});
} catch (RuntimeException e) {
e.printStackTrace();
}
LOGGER.info("{}", kakaoMethod);
}
public Object getResult(String intent, KakaoRequest req) {
Method method = kakaoMethod.get(intent);
LOGGER.info(intent);
LOGGER.info(String.valueOf(method));
Object result = null;
try {
result = method.invoke(kakaoIntentApp, req);
} catch (Exception e) {
result = kakaoIntentApp.
getError("method 실행 에러 {}", e.getMessage());
LOGGER.error(String.valueOf(e));
}
return result;
}
}
@@ -0,0 +1,16 @@
package com.ex.chatbot.kakao.response.build;
import java.util.regex.Pattern;
public class ValidCheck {
public static boolean isCarNo(String carNo) {
String pattern = "\\d{2,3}[가-힣]\\d{4}$";
String pattern2 = "^[서울|부산|대구|인천|대전|광주|울산|제주|경기|강원|충남|전남|전북|경남|경북|세종]{2}\\d{2,3}[가-힣]\\d{4}$";
if (Pattern.matches(pattern, carNo) || Pattern.matches(pattern2, carNo)) {
return true;
}
return false;
}
}
@@ -0,0 +1,132 @@
package com.ex.chatbot.kakao.response.element;
import com.ex.chatbot.kakao.response.element.template.Template;
import com.ex.chatbot.kakao.response.element.type.ContextControl;
import com.ex.chatbot.kakao.response.element.type.ContextValue;
import com.ex.chatbot.kakao.response.element.type.QuickReply;
import com.ex.chatbot.kakao.response.element.type.SkillTemplate;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
@Data
@Component
public class SkillResponse {
public final static String KAKAO_VERSION = "2.0";
@JsonProperty("version")
private String version;
@JsonProperty("useCallback")
private boolean useCallback;
@JsonProperty("template")
private SkillTemplate template;
@JsonProperty("context")
private ContextControl context;
@JsonProperty("data")
private Map<String, Object> data;
public SkillResponse() {
this.version = KAKAO_VERSION;
this.template = new SkillTemplate();
this.useCallback = false; // 기본값 false
}
public SkillResponse(String version, boolean useCallback, SkillTemplate template, ContextControl context, Map<String, Object> data) {
this.version = version;
this.useCallback = useCallback;
this.template = template;
this.context = context;
this.data = data;
}
// SkillTemplate을 인자로 받는 생성자
public SkillResponse(SkillTemplate template) {
this.version = KAKAO_VERSION;
this.template = template;
this.useCallback = false; // 기본값 false
}
// useCallback을 인자로 받는 생성자
public SkillResponse(boolean useCallback) {
this.version = KAKAO_VERSION;
this.template = new SkillTemplate();
this.useCallback = useCallback;
}
public SkillResponse getResult(Template output, List<QuickReply> quickReplies) {
this.template = SkillTemplate.builder()
.addOutput(output)
.quickReplies(quickReplies)
.build();
return this;
}
public SkillResponse getResult(List<Template> outputs, List<QuickReply> quickReplies) {
this.template = SkillTemplate.builder()
.outputs(outputs)
.quickReplies(quickReplies)
.build();
return this;
}
public SkillResponse getResult(List<Template> outputs, List<QuickReply> quickReplies, List<ContextValue> values) {
this.template = SkillTemplate.builder()
.outputs(outputs)
.quickReplies(quickReplies)
.build();
this.context = ContextControl.builder()
.values(values)
.build();
return this;
}
// data 필드를 설정하는 메서드 추가
public SkillResponse setData(Map<String, Object> data) {
this.data = data;
return this;
}
// 수동으로 빌더 패턴 구현
public static SkillResponseBuilder builder() {
return new SkillResponseBuilder();
}
public static class SkillResponseBuilder {
private String version = KAKAO_VERSION;
private boolean useCallback;
private SkillTemplate template;
private ContextControl context;
private Map<String, Object> data;
public SkillResponseBuilder version(String version) {
this.version = version;
return this;
}
public SkillResponseBuilder useCallback(boolean useCallback) {
this.useCallback = useCallback;
return this;
}
public SkillResponseBuilder template(SkillTemplate template) {
this.template = template;
return this;
}
public SkillResponseBuilder context(ContextControl context) {
this.context = context;
return this;
}
public SkillResponseBuilder data(Map<String, Object> data) {
this.data = data;
return this;
}
public SkillResponse build() {
return new SkillResponse(version, useCallback, template, context, data);
}
}
}
@@ -0,0 +1,29 @@
package com.ex.chatbot.kakao.response.element.View;
import com.ex.chatbot.kakao.response.element.template.BasicCard;
import com.ex.chatbot.kakao.response.element.template.SimpleText;
import com.ex.chatbot.kakao.response.element.template.Template;
import lombok.Builder;
import lombok.Getter;
import lombok.ToString;
@Getter
@Builder
@ToString
public class BasicCardView implements Template, CanCarousel {
private BasicCard basicCard;
static public Template getSimpleText(String textbody) {
SimpleTextView stv =
SimpleTextView.builder()
.simpleText(
SimpleText.builder()
.text(textbody)
.build())
.build();
return stv;
}
}
@@ -0,0 +1,5 @@
package com.ex.chatbot.kakao.response.element.View;
public interface CanCarousel {
}
@@ -0,0 +1,16 @@
package com.ex.chatbot.kakao.response.element.View;
import com.ex.chatbot.kakao.response.element.template.Carousel;
import com.ex.chatbot.kakao.response.element.template.Template;
import lombok.Builder;
import lombok.Getter;
import lombok.ToString;
@Getter
@Builder
@ToString
public class CarouselView implements Template {
private Carousel carousel;
}
@@ -0,0 +1,15 @@
package com.ex.chatbot.kakao.response.element.View;
import com.ex.chatbot.kakao.response.element.template.CommerceCard;
import com.ex.chatbot.kakao.response.element.template.Template;
import lombok.Builder;
import lombok.Getter;
import lombok.ToString;
@Getter
@Builder
@ToString
public class CommerceCardView implements Template, CanCarousel {
private CommerceCard commerceCard;
}
@@ -0,0 +1,16 @@
package com.ex.chatbot.kakao.response.element.View;
import com.ex.chatbot.kakao.response.element.template.ListCard;
import com.ex.chatbot.kakao.response.element.template.Template;
import lombok.Builder;
import lombok.Getter;
import lombok.ToString;
@Getter
@Builder
@ToString
public class ListCardView implements Template {
private ListCard listCard;
}
@@ -0,0 +1,15 @@
package com.ex.chatbot.kakao.response.element.View;
import com.ex.chatbot.kakao.response.element.template.SimpleImage;
import com.ex.chatbot.kakao.response.element.template.Template;
import lombok.Builder;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
@Builder
public class SimpleImageView implements Template {
private SimpleImage simpleImage;
}
@@ -0,0 +1,15 @@
package com.ex.chatbot.kakao.response.element.View;
import com.ex.chatbot.kakao.response.element.template.SimpleText;
import com.ex.chatbot.kakao.response.element.template.Template;
import lombok.Builder;
import lombok.Data;
@Builder
@Data
public class SimpleTextView implements Template {
private SimpleText simpleText;
}
@@ -0,0 +1,40 @@
package com.ex.chatbot.kakao.response.element.template;
import com.ex.chatbot.kakao.response.element.View.BasicCardView;
import com.ex.chatbot.kakao.response.element.type.buttons.Button;
import com.ex.chatbot.kakao.response.element.type.subtype.Profile;
import com.ex.chatbot.kakao.response.element.type.subtype.Social;
import com.ex.chatbot.kakao.response.element.type.subtype.Thumbnail;
import lombok.Builder;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Builder
@Data
public class BasicCard implements Template {
private String title;
private String description;
private Thumbnail thumbnail;
private Profile profile;
private Social social;
private List<Button> buttons;
static public Template getBasicCard(String title, String description, String url, Profile profile, ArrayList<Button> buttons) {
BasicCardView bcv =
BasicCardView.builder()
.basicCard(
BasicCard.builder()
.title(title)
.description(description)
.thumbnail(Thumbnail.builder()
.imageUrl(url)
.build())
.buttons(buttons)
.build())
.build();
return bcv;
}
}
@@ -0,0 +1,27 @@
package com.ex.chatbot.kakao.response.element.template;
import com.ex.chatbot.kakao.response.element.View.CarouselView;
import com.ex.chatbot.kakao.response.element.type.subtype.CarouselHeader;
import lombok.Builder;
import lombok.Data;
import lombok.Singular;
import java.util.List;
@Builder
@Data
public class Carousel implements Template {
private String type;
private CarouselHeader header;
@Singular("addItem")
private List<Template> items; //BasicCard or CommercCard 만 가능
static public Template getCarousel(String type, List<Template> items) {
CarouselView cv = CarouselView.builder()
.carousel(Carousel.builder()
.type(type)
.items(items).build())
.build();
return cv;
}
}
@@ -0,0 +1,40 @@
package com.ex.chatbot.kakao.response.element.template;
import com.ex.chatbot.kakao.response.element.View.CommerceCardView;
import com.ex.chatbot.kakao.response.element.type.buttons.Button;
import com.ex.chatbot.kakao.response.element.type.subtype.Profile;
import com.ex.chatbot.kakao.response.element.type.subtype.Thumbnail;
import lombok.Builder;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Builder
@Data
public class CommerceCard implements Template {
private String description;
private Comparable price;
private Comparable currency;
private Comparable discount;
private Comparable discountRate;
private Comparable discountedPrice;
private ArrayList<Thumbnail> thumbnails;
private Profile profile;
private List<Button> buttons;
static public Template getCommerceCard(String description, Comparable price, String currency, ArrayList<Thumbnail> thumbnails, Profile profile, ArrayList<Button> button) {
CommerceCardView ccv = CommerceCardView.builder()
.commerceCard(CommerceCard.builder()
.description(description)
.price(price)
.currency(currency)
.thumbnails(thumbnails)
.profile(profile)
.buttons(button)
.build())
.build();
return ccv;
}
}
@@ -0,0 +1,34 @@
package com.ex.chatbot.kakao.response.element.template;
import com.ex.chatbot.kakao.response.element.View.ListCardView;
import com.ex.chatbot.kakao.response.element.type.Item;
import com.ex.chatbot.kakao.response.element.type.buttons.Button;
import lombok.Builder;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
@Builder
@Data
public class ListCard implements Template {
private Item header;
//private ListItem items;
private List<Item> items;
private List<Button> buttons;
static public Template getListCard(String title, String url, List<Item> items, ArrayList<Button> buttons) {
ListCardView lcv = ListCardView.builder()
.listCard(ListCard.builder()
.header(Item.builder()
.title(title)
.imageUrl(url)
.build())
.items(items)
.buttons(buttons)
.build())
.build();
return lcv;
}
}
@@ -0,0 +1,25 @@
package com.ex.chatbot.kakao.response.element.template;
import com.ex.chatbot.kakao.response.element.View.SimpleImageView;
import lombok.Builder;
import lombok.Data;
@Builder
@Data
public class SimpleImage implements Template {
private String imageUrl;
private String altText;
static public Template getSimpleImage(String url, String altTxt) {
SimpleImageView siv =
SimpleImageView.builder()
.simpleImage(
SimpleImage.builder()
.imageUrl(url)
.altText(altTxt)
.build())
.build();
return siv;
}
}
@@ -0,0 +1,23 @@
package com.ex.chatbot.kakao.response.element.template;
import com.ex.chatbot.kakao.response.element.View.SimpleTextView;
import lombok.Builder;
import lombok.Data;
@Builder
@Data
public class SimpleText implements Template {
private String text;
static public Template getSimpleText(String textbody) {
SimpleTextView stv =
SimpleTextView.builder()
.simpleText(
SimpleText.builder()
.text(textbody)
.build())
.build();
return stv;
}
}
@@ -0,0 +1,6 @@
package com.ex.chatbot.kakao.response.element.template;
public interface Template {
}
@@ -0,0 +1,9 @@
package com.ex.chatbot.kakao.response.element.type;
import lombok.Data;
@Data
public class Block {
String id;
String name;
}
@@ -0,0 +1,12 @@
package com.ex.chatbot.kakao.response.element.type;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Data
@Builder
public class ContextControl {
List<ContextValue> values;
}
@@ -0,0 +1,14 @@
package com.ex.chatbot.kakao.response.element.type;
import lombok.Builder;
import lombok.Data;
import java.util.Map;
@Data
@Builder
public class ContextValue {
String name;
int lifeSpan;
Map<String, String> params;
}
@@ -0,0 +1,8 @@
package com.ex.chatbot.kakao.response.element.type;
import lombok.Data;
@Data
public class DetailParam {
// Not Yet.
}
@@ -0,0 +1,17 @@
package com.ex.chatbot.kakao.response.element.type;
import com.ex.chatbot.kakao.response.element.type.subtype.Link;
import lombok.Builder;
import lombok.Data;
@Builder
@Data
public class Item {
private String title;
private String description;
private String imageUrl;
private Link link;
}
@@ -0,0 +1,34 @@
package com.ex.chatbot.kakao.response.element.type;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class QuickReply {
private String label;
private String action;
private String messageText;
private String blockId;
private Object extra;
public static QuickReply getMessageReply(String label, String messageText) {
QuickReply quickReply = QuickReply.builder()
.label(label)
.action("message")
.messageText(messageText)
.build();
return quickReply;
}
public static QuickReply getBlockReply(String label, String blockId) {
QuickReply quickReply = QuickReply.builder()
.label(label)
.action("block")
.messageText(null)
.blockId(blockId)
.build();
return quickReply;
}
}
@@ -0,0 +1,78 @@
package com.ex.chatbot.kakao.response.element.type;
import com.ex.chatbot.kakao.response.element.template.Template;
import lombok.Builder;
import lombok.Data;
import lombok.Singular;
import java.util.ArrayList;
import java.util.List;
@Builder
@Data
public class SkillTemplate {
final static private Character MAX_OUTPUTS_SIZE = 3;
final static private Character MIN_OUTPUTS_SIZE = 1;
final static private Character MAX_QUICKREPLY_SIZE = 10;
@Singular("addOutput")
private List<Template> outputs;
private List<QuickReply> quickReplies;
public SkillTemplate(){
this.outputs = new ArrayList<>();
this.quickReplies = new ArrayList<>();
}
public SkillTemplate(List<Template> ou, List<QuickReply> qr){
this.outputs = ou;
this.quickReplies = qr;
}
public void addOutput(Template ou){
if(this.outputs.size() + 1 > MAX_OUTPUTS_SIZE){
throw new IndexOutOfBoundsException("outputs size must be at least 1 and no more than 3.");
}
this.outputs.add(ou);
}
public void initOutputs(){
this.outputs = new ArrayList<Template>();
}
public void setOutputs(ArrayList<Template> outputs){
if(outputs.size() > MAX_OUTPUTS_SIZE){
throw new IndexOutOfBoundsException("outputs size must be at least 1 and no more than 3.");
}
this.outputs = outputs;
}
public void setOutput(Template outputs){
this.outputs = new ArrayList<Template>();
this.outputs.add(outputs);
}
public void addQuickReply(QuickReply qr){
if(this.quickReplies.size() + 1 > MAX_QUICKREPLY_SIZE){
throw new IndexOutOfBoundsException("quickReplies size must be no more than 10.");
}
this.quickReplies.add(qr);
}
public void initQuickReplies(){
this.quickReplies = new ArrayList<QuickReply>();
}
public void setQuickReplies(List<QuickReply> quickReplies){
if ( quickReplies.size() > MAX_QUICKREPLY_SIZE ){
throw new IndexOutOfBoundsException("quickReplies size must be no more than 10.");
}
this.quickReplies = quickReplies;
}
public void setQuickReplies(QuickReply qreply){
this.quickReplies = new ArrayList<>();
this.quickReplies.add(qreply);
}
}
@@ -0,0 +1,17 @@
package com.ex.chatbot.kakao.response.element.type;
import com.ex.chatbot.kakao.response.element.type.subtype.Properties;
import lombok.Builder;
import lombok.Getter;
import lombok.ToString;
@Getter
@Builder
@ToString
public class User {
public Properties properties;
private String id;
private String type;
}
@@ -0,0 +1,15 @@
package com.ex.chatbot.kakao.response.element.type.buttons;
import lombok.Builder;
import lombok.Data;
@Builder
@Data
public class BlockButton implements Button {
private String label;
@Builder.Default
private String action = "block";
private String messageText;
private String blockId;
}
@@ -0,0 +1,8 @@
package com.ex.chatbot.kakao.response.element.type.buttons;
import java.util.Map;
public interface Button {
Map<String, Object> extra = null;
}
@@ -0,0 +1,15 @@
package com.ex.chatbot.kakao.response.element.type.buttons;
import lombok.Builder;
import lombok.Data;
@Builder
@Data
public class MessageButton implements Button {
String label;
@Builder.Default
private String action = "message";
private String messageText;
}
@@ -0,0 +1,26 @@
package com.ex.chatbot.kakao.response.element.type.buttons;
import com.ex.chatbot.kakao.response.element.type.subtype.Link;
import lombok.Builder;
import lombok.Data;
@Builder
@Data
public class OsLinkButton implements Button {
private String label;
@Builder.Default
private String action = "osLink";
private Link osLink;
public static OsLinkButton getOsLinkButton(String label, String pc, String android, String ios) {
OsLinkButton button = OsLinkButton.builder()
.label(label)
.osLink(Link.builder()
.pc(pc)
.android(android)
.ios(ios)
.build())
.build();
return button;
}
}
@@ -0,0 +1,22 @@
package com.ex.chatbot.kakao.response.element.type.buttons;
import lombok.Builder;
import lombok.Data;
@Builder
@Data
public class PhoneButton implements Button {
private String label;
@Builder.Default
private String action = "phone";
private String phoneNumber;
public static PhoneButton getPhoneButton(String label, String phoneNumber ) {
PhoneButton button = PhoneButton.builder()
.label(label)
.phoneNumber(phoneNumber)
.build();
return button;
}
}
@@ -0,0 +1,12 @@
package com.ex.chatbot.kakao.response.element.type.buttons;
import lombok.Builder;
import lombok.Data;
@Builder
@Data
public class ShareButton implements Button {
private String label;
@Builder.Default
private String action = "share";
}
@@ -0,0 +1,22 @@
package com.ex.chatbot.kakao.response.element.type.buttons;
import lombok.Builder;
import lombok.Data;
@Builder
@Data
public class WebLinkButton implements Button {
private String label;
@Builder.Default
private String action = "webLink";
private String webLinkUrl;
public static WebLinkButton gWebLinkButton(String label, String webLinkUrl){
WebLinkButton button = WebLinkButton.builder()
.label(label)
.webLinkUrl(webLinkUrl)
.build();
return button;
}
}
@@ -0,0 +1,12 @@
package com.ex.chatbot.kakao.response.element.type.subtype;
import lombok.Data;
@Data
public class CarouselHeader {
private String title;
private String description;
private Thumbnail thumbnail;
}
@@ -0,0 +1,18 @@
package com.ex.chatbot.kakao.response.element.type.subtype;
import lombok.Builder;
import lombok.Getter;
import lombok.ToString;
@Getter
@Builder
@ToString
public class Link {
private String mobile;
private String ios;
private String android;
private String pc;
private String mac;
private String win;
private String web;
}
@@ -0,0 +1,11 @@
package com.ex.chatbot.kakao.response.element.type.subtype;
import lombok.Builder;
import lombok.Data;
@Builder
@Data
public class Profile {
private String nickname;
private String imageUrl;
}
@@ -0,0 +1,14 @@
package com.ex.chatbot.kakao.response.element.type.subtype;
import lombok.Builder;
import lombok.Getter;
import lombok.ToString;
@Getter
@Builder
@ToString
public class Properties {
private String plusfriendUserKey;
private String appUserId;
}
@@ -0,0 +1,15 @@
package com.ex.chatbot.kakao.response.element.type.subtype;
import lombok.Builder;
import lombok.Getter;
import lombok.ToString;
@Getter
@Builder
@ToString
public class Social {
private Long like;
private Long comment;
private Long share;
}
@@ -0,0 +1,17 @@
package com.ex.chatbot.kakao.response.element.type.subtype;
import lombok.Builder;
import lombok.Getter;
import lombok.ToString;
@Getter
@Builder
@ToString
public class Thumbnail {
private String imageUrl;
private Link link;
private Boolean fixedRaio;
private Integer width;
private Integer height;
}
@@ -0,0 +1,285 @@
package com.ex.chatbot.kakao.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
* 한국도로공사 데이터포털 (data.ex.co.kr) 외부 API 호출 서비스
* Web(skill) 계층에서 직접 호출 — WAS(chatbotApi)에서는 네트워크 접근 불가
*/
@Slf4j
@Service
public class DataPortalService {
private final RestTemplate restTemplate;
@Value("${data-portal.base-url}")
private String baseUrl;
@Value("${data-portal.api-key}")
private String apiKey;
@Value("${data-portal.max-rows:100}")
private String maxRows;
public DataPortalService(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
// ──────────────────────────────────────────────────────────────
// 주유소 현황
// ──────────────────────────────────────────────────────────────
@SuppressWarnings("unchecked")
public Map<String, Object> getGasstationInfo(String restAreaName, String restAreaDirection) {
log.info("{} 휴게소 주유 정보 조회 시작 (방향: {})", restAreaName, restAreaDirection);
Map<String, Object> response = new HashMap<>();
try {
String url = baseUrl + "/business/curStateStation?key=" + apiKey + "&type=json"
+ "&numOfRows=" + maxRows + "&pageNo=1"
+ "&serviceAreaName=" + restAreaName;
Map<String, Object> apiResult = restTemplate.getForObject(url, Map.class);
if (apiResult == null) return errorResponse("데이터포털 응답이 없습니다.");
int count = toInt(apiResult.get("count"));
log.info("[data.ex.co.kr][주유] {} 휴게소 원본 count={}", restAreaName, count);
if (count == 0)
return errorResponse(restAreaName + " 휴게소 주유(충전소) 정보가 없습니다.");
List<Map<String, Object>> list = (List<Map<String, Object>>) apiResult.get("list");
if (list == null || list.isEmpty())
return errorResponse(restAreaName + " 휴게소 주유(충전소) 정보가 없습니다.");
log.info("[data.ex.co.kr][주유] {} 휴게소 원본 listSize={}", restAreaName, list.size());
list = list.stream()
.filter(g -> {
String nm = (String) g.get("serviceAreaName");
return nm != null && nm.startsWith(restAreaName);
})
.collect(Collectors.toList());
log.info("[data.ex.co.kr][주유] {} 휴게소 이름 필터 후 listSize={}", restAreaName, list.size());
if (list.isEmpty()) return errorResponse(restAreaName + " 휴게소 주유(충전소) 정보가 없습니다.");
boolean bidirectional = list.size() >= 2 && restAreaDirection == null;
if (bidirectional) {
List<String> directions = list.stream()
.map(g -> (String) g.get("direction"))
.distinct()
.collect(Collectors.toList());
response.put("status", true);
response.put("statusMsg", "정상응답");
response.put("restAreaName", restAreaName);
response.put("bidirectional", true);
response.put("directions", directions);
response.put("list", list);
log.info("[data.ex.co.kr][주유] {} 휴게소 조회 성공 - bidirectional=true, directions={}, listSize={}",
restAreaName, directions, list.size());
return response;
}
String anotherDirection = null;
if (restAreaDirection != null) {
String dirFilter = restAreaDirection.replace("방향", "");
for (Map<String, Object> g : list) {
String dir = (String) g.get("direction");
if (dir != null && !dir.equals(dirFilter)) anotherDirection = dir;
}
final String finalDirFilter = dirFilter;
list = list.stream()
.filter(g -> finalDirFilter.equals(g.get("direction")))
.collect(Collectors.toList());
log.info("[data.ex.co.kr][주유] {} 휴게소 방향 필터({}) 후 listSize={}",
restAreaName, finalDirFilter, list.size());
}
if (list.isEmpty())
return errorResponse(restAreaName + " 휴게소 " + restAreaDirection + " 방향 주유정보가 없습니다.");
response.put("status", true);
response.put("statusMsg", "정상응답");
response.put("restAreaName", restAreaName);
response.put("bidirectional", false);
response.put("anotherDirection", anotherDirection);
response.put("list", list);
log.info("[data.ex.co.kr][주유] {} 휴게소 조회 성공 - direction={}, anotherDirection={}, listSize={}",
restAreaName, restAreaDirection, anotherDirection, list.size());
return response;
} catch (Exception e) {
log.error("주유소 정보 조회 오류: {}", e.getMessage(), e);
return errorResponse("서비스에 일시적인 장애가 발생하였습니다.");
}
}
// ──────────────────────────────────────────────────────────────
// 브랜드 매장 목록
// ──────────────────────────────────────────────────────────────
@SuppressWarnings("unchecked")
public Map<String, Object> getBrandShopList(String restAreaName, String brand, Integer startIndex) {
log.info("{} 휴게소 브랜드 매장 정보 조회 시작 (brand: {}, startIndex: {})", restAreaName, brand, startIndex);
Map<String, Object> response = new HashMap<>();
try {
String url = baseUrl + "/restinfo/restBrandList?key=" + apiKey + "&type=json"
+ "&numOfRows=" + maxRows + "&pageNo=1"
+ "&stdRestNm=" + restAreaName;
Map<String, Object> apiResult = restTemplate.getForObject(url, Map.class);
if (apiResult == null) return errorResponse("데이터포털 응답이 없습니다.");
List<Map<String, Object>> list = (List<Map<String, Object>>) apiResult.get("list");
if (list == null || list.isEmpty())
return errorResponse(restAreaName + " 휴게소 매장 정보가 없습니다.");
log.info("[data.ex.co.kr][브랜드] {} 휴게소 원본 listSize={}, brand={}",
restAreaName, list.size(), brand);
String regex = "(^" + restAreaName + "\\([가-힣]*\\)휴게소$)|(^" + restAreaName + "휴게소$)";
Pattern p = Pattern.compile(regex);
list = list.stream()
.filter(b -> {
String nm = (String) b.get("stdRestNm");
return nm != null && p.matcher(nm).find();
})
.collect(Collectors.toList());
log.info("[data.ex.co.kr][브랜드] {} 휴게소 이름 필터 후 listSize={}", restAreaName, list.size());
if (brand != null) {
final String brandFilter = brand;
list = list.stream()
.filter(b -> brandFilter.equals(b.get("brdName")))
.collect(Collectors.toList());
log.info("[data.ex.co.kr][브랜드] {} 휴게소 브랜드 필터({}) 후 listSize={}",
restAreaName, brandFilter, list.size());
if (list.isEmpty())
return errorResponse(restAreaName + " 휴게소에는 " + brand + " 매장이 없습니다.");
}
if (list.isEmpty()) return errorResponse(restAreaName + " 휴게소 매장 정보가 없습니다.");
int totalCount = list.size();
if (startIndex != null && startIndex > 0 && startIndex < totalCount) {
list = list.subList(startIndex, totalCount);
}
response.put("status", true);
response.put("statusMsg", "정상응답");
response.put("restAreaName", restAreaName);
response.put("brand", brand);
response.put("totalCount", totalCount);
response.put("startIndex", startIndex != null ? startIndex : 0);
response.put("list", list);
log.info("[data.ex.co.kr][브랜드] {} 휴게소 조회 성공 - brand={}, totalCount={}, returnedSize={}",
restAreaName, brand, totalCount, list.size());
return response;
} catch (Exception e) {
log.error("브랜드 매장 정보 조회 오류: {}", e.getMessage(), e);
return errorResponse("서비스에 일시적인 장애가 발생하였습니다.");
}
}
// ──────────────────────────────────────────────────────────────
// 음식 목록
// ──────────────────────────────────────────────────────────────
@SuppressWarnings("unchecked")
public Map<String, Object> getFoodList(String restAreaName) {
log.info("{} 휴게소 음식 목록 조회 시작", restAreaName);
Map<String, Object> response = new HashMap<>();
try {
String url = baseUrl + "/restinfo/restBestfoodList?key=" + apiKey + "&type=json"
+ "&numOfRows=" + maxRows + "&pageNo=1"
+ "&stdRestNm=" + restAreaName;
Map<String, Object> apiResult = restTemplate.getForObject(url, Map.class);
if (apiResult == null) return errorResponse("데이터포털 응답이 없습니다.");
int count = toInt(apiResult.get("count"));
log.info("[data.ex.co.kr][음식] {} 휴게소 원본 count={}", restAreaName, count);
if (count == 0)
return errorResponse(restAreaName + " 휴게소 식사 메뉴 정보가 없습니다.");
List<Map<String, Object>> list = (List<Map<String, Object>>) apiResult.get("list");
if (list == null || list.isEmpty())
return errorResponse(restAreaName + " 휴게소 식사 메뉴 정보가 없습니다.");
log.info("[data.ex.co.kr][음식] {} 휴게소 원본 listSize={}", restAreaName, list.size());
String regex = "(^" + restAreaName + "\\([가-힣]*\\)휴게소$)|(^" + restAreaName + "휴게소$)";
Pattern p = Pattern.compile(regex);
list = list.stream()
.filter(f -> {
String nm = (String) f.get("stdRestNm");
return nm != null && p.matcher(nm).find();
})
.collect(Collectors.toList());
log.info("[data.ex.co.kr][음식] {} 휴게소 이름 필터 후 listSize={}", restAreaName, list.size());
if (list.isEmpty()) return errorResponse(restAreaName + " 휴게소 식사 메뉴 정보가 없습니다.");
Map<String, StringBuilder> msgMap = new HashMap<>();
for (Map<String, Object> foodInfo : list) {
String stdRestNm = valueAsString(foodInfo.get("stdRestNm"));
msgMap.computeIfAbsent(stdRestNm, k -> new StringBuilder());
String foodNm = valueAsString(foodInfo.get("foodNm"));
String foodCost = valueAsString(foodInfo.get("foodCost"));
String price = "0".equals(foodCost) ? "(선택 메뉴에 따라 가격 상이)" : foodCost + "원";
msgMap.get(stdRestNm).append("- ").append(foodNm).append(" ").append(price).append("\n");
}
Map<String, String> msgMapStr = new HashMap<>();
msgMap.forEach((k, v) -> msgMapStr.put(k, v.toString().trim()));
response.put("status", true);
response.put("statusMsg", "정상응답");
response.put("restAreaName", restAreaName);
response.put("list", list);
response.put("msgMap", msgMapStr);
log.info("[data.ex.co.kr][음식] {} 휴게소 조회 성공 - listSize={}, msgMapKeys={}",
restAreaName, list.size(), msgMapStr.keySet());
return response;
} catch (Exception e) {
log.error("음식 목록 조회 오류: {}", e.getMessage(), e);
return errorResponse("서비스에 일시적인 장애가 발생하였습니다.");
}
}
private Map<String, Object> errorResponse(String msg) {
log.warn("[data.ex.co.kr] 조회 실패 - {}", msg);
Map<String, Object> r = new HashMap<>();
r.put("status", false);
r.put("statusMsg", msg);
return r;
}
private int toInt(Object value) {
if (value instanceof Number) {
return ((Number) value).intValue();
}
if (value == null) {
return 0;
}
try {
return Integer.parseInt(String.valueOf(value));
} catch (NumberFormatException e) {
return 0;
}
}
private String valueAsString(Object value) {
if (value == null) {
return "";
}
return String.valueOf(value).trim();
}
}
@@ -0,0 +1,123 @@
package com.ex.chatbot.kakao.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* WAS Agent tool이 호출하는 WEB domain fetch (data.ex.co.kr).
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class InternalDataPortalToolService {
private final DataPortalService dataPortalService;
public Map<String, Object> fetch(String intentType, Map<String, Object> params, String userInput) {
Map<String, Object> safeParams = params != null ? new HashMap<>(params) : new HashMap<>();
Map<String, Object> domainData = fetchByIntent(intentType, safeParams);
if ("GASSTATION".equals(intentType)) {
domainData = refineGasstationDirectionFromUserInput(intentType, safeParams, userInput, domainData);
}
return domainData;
}
private Map<String, Object> fetchByIntent(String intentType, Map<String, Object> params) {
String restAreaName = stringValue(params.get("restAreaName"));
if ("GASSTATION".equals(intentType)) {
return dataPortalService.getGasstationInfo(
restAreaName,
stringValue(params.get("restAreaDirection"))
);
}
if ("BRAND_SHOP".equals(intentType)) {
return dataPortalService.getBrandShopList(
restAreaName,
stringValue(params.get("brand")),
integerValue(params.get("startIndex"))
);
}
if ("REST_AREA_FOOD_LIST".equals(intentType)) {
return dataPortalService.getFoodList(restAreaName);
}
Map<String, Object> unsupported = new HashMap<>();
unsupported.put("status", false);
unsupported.put("statusMsg", "지원하지 않는 외부 데이터 조회 유형입니다: " + intentType);
return unsupported;
}
@SuppressWarnings("unchecked")
private Map<String, Object> refineGasstationDirectionFromUserInput(
String intentType,
Map<String, Object> params,
String userInput,
Map<String, Object> domainData
) {
if (domainData == null || userInput == null || userInput.isBlank()) {
return domainData;
}
if (params.get("restAreaDirection") != null) {
return domainData;
}
if (!Boolean.TRUE.equals(domainData.get("bidirectional"))) {
return domainData;
}
Object rawDirections = domainData.get("directions");
if (!(rawDirections instanceof List)) {
return domainData;
}
String normalizedInput = normalizeDirectionText(userInput);
for (Object directionObj : (List<Object>) rawDirections) {
String direction = stringValue(directionObj);
if (direction == null) {
continue;
}
if (normalizedInput.contains(normalizeDirectionText(direction))) {
params.put("restAreaDirection", direction);
log.info("[InternalTool] 주유소 방향 보정 - direction: {}", direction);
return fetchByIntent(intentType, params);
}
}
return domainData;
}
private String normalizeDirectionText(Object value) {
String text = stringValue(value);
if (text == null) {
return "";
}
return text.replaceAll("\\s+", "")
.replace("방향", "")
.replace("행", "");
}
private String stringValue(Object value) {
if (value == null) {
return null;
}
String text = String.valueOf(value).trim();
return text.isEmpty() ? null : text;
}
private Integer integerValue(Object value) {
if (value == null) {
return null;
}
if (value instanceof Number) {
return ((Number) value).intValue();
}
try {
return Integer.parseInt(String.valueOf(value).trim());
} catch (NumberFormatException e) {
return null;
}
}
}
@@ -0,0 +1,61 @@
package com.ex.chatbot.kakao.service;
import com.ex.chatbot.kakao.config.AgentProperties;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.util.HashMap;
import java.util.Map;
@Slf4j
@Service
public class RagAgentClient {
private final RestTemplate agentRestTemplate;
private final AgentProperties agentProperties;
public RagAgentClient(
@Qualifier("agentRestTemplate") RestTemplate agentRestTemplate,
AgentProperties agentProperties
) {
this.agentRestTemplate = agentRestTemplate;
this.agentProperties = agentProperties;
}
@SuppressWarnings("unchecked")
public Map<String, Object> chat(String query, String botId) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
Map<String, Object> body = new HashMap<>();
body.put("query", query);
body.put("botId", botId != null ? botId : "anonymous");
long started = System.currentTimeMillis();
Map<String, Object> response = agentRestTemplate.postForObject(
agentProperties.getChat().getUrl(),
new HttpEntity<>(body, headers),
Map.class
);
long elapsed = System.currentTimeMillis() - started;
log.info(
"[Agent] POST /agent/chat elapsed_ms={} routeType={} intentType={}",
elapsed,
response != null ? response.get("routeType") : null,
response != null ? response.get("intentType") : null
);
if (response == null) {
Map<String, Object> fallback = new HashMap<>();
fallback.put("answer", "죄송합니다. 답변을 생성하지 못했습니다.");
return fallback;
}
return response;
}
}
@@ -0,0 +1,14 @@
package com.ex.chatbot.kakao.skillRequest;
import lombok.Data;
import java.util.Map;
@Data
public class Action {
String name;
Map<String, Object> clientExtra;
Map<String, String> params;
String id;
Map<String, Object> detailParams;
}
@@ -0,0 +1,10 @@
package com.ex.chatbot.kakao.skillRequest;
import lombok.Data;
@Data
public class Bot {
private String id;
private String name;
}
@@ -0,0 +1,64 @@
package com.ex.chatbot.kakao.skillRequest;
import com.ex.chatbot.kakao.response.element.type.ContextControl;
import com.ex.chatbot.kakao.response.element.type.SkillTemplate;
import com.fasterxml.jackson.annotation.JsonProperty;
public class CallbackRequest {
@JsonProperty("version")
private String version;
@JsonProperty("template")
private SkillTemplate template;
@JsonProperty("context")
private ContextControl context;
@JsonProperty("useCallback")
private boolean useCallback;
public CallbackRequest() {
this.version = "2.0";
this.useCallback = true;
}
public CallbackRequest(SkillTemplate template, ContextControl context) {
this.version = "2.0";
this.template = template;
this.context = context;
this.useCallback = true;
}
// Getters and Setters
public String getVersion() {
return version;
}
public void setVersion(String version) {
this.version = version;
}
public SkillTemplate getTemplate() {
return template;
}
public void setTemplate(SkillTemplate template) {
this.template = template;
}
public ContextControl getContext() {
return context;
}
public void setContext(ContextControl context) {
this.context = context;
}
public boolean isUseCallback() {
return useCallback;
}
public void setUseCallback(boolean useCallback) {
this.useCallback = useCallback;
}
}
@@ -0,0 +1,9 @@
package com.ex.chatbot.kakao.skillRequest;
import lombok.Data;
@Data
public class Intent {
private String id;
private String name;
}
@@ -0,0 +1,12 @@
package com.ex.chatbot.kakao.skillRequest;
import lombok.Data;
@Data
public class KakaoRequest {
private Intent intent;
private UserRequest userRequest;
private Bot bot;
private Action action;
}
@@ -0,0 +1,16 @@
package com.ex.chatbot.kakao.skillRequest;
import lombok.Data;
import java.util.Map;
@Data
public class UserRequest {
private String timezone;
private Map<String, Object> params;
private Map<String, Object> block;
private String Utterance;
private String lang;
private Map<String, Object> user;
private String callbackUrl;
}
@@ -0,0 +1,413 @@
package com.ex.chatbot.kakao.web;
import com.ex.chatbot.kakao.response.element.SkillResponse;
import com.ex.chatbot.kakao.response.element.View.BasicCardView;
import com.ex.chatbot.kakao.response.element.View.ListCardView;
import com.ex.chatbot.kakao.response.element.View.SimpleTextView;
import com.ex.chatbot.kakao.response.element.template.BasicCard;
import com.ex.chatbot.kakao.response.element.template.ListCard;
import com.ex.chatbot.kakao.response.element.template.SimpleText;
import com.ex.chatbot.kakao.response.element.template.Template;
import com.ex.chatbot.kakao.response.element.type.Item;
import com.ex.chatbot.kakao.response.element.type.QuickReply;
import com.ex.chatbot.kakao.response.element.type.buttons.BlockButton;
import com.ex.chatbot.kakao.response.element.type.buttons.Button;
import com.ex.chatbot.kakao.response.element.type.buttons.MessageButton;
import com.ex.chatbot.kakao.response.element.type.buttons.OsLinkButton;
import com.ex.chatbot.kakao.response.element.type.buttons.PhoneButton;
import com.ex.chatbot.kakao.response.element.type.buttons.WebLinkButton;
import com.ex.chatbot.kakao.response.element.type.subtype.Link;
import com.ex.chatbot.kakao.web.dto.WebActionButtonDto;
import com.ex.chatbot.kakao.web.dto.WebListCardDto;
import com.ex.chatbot.kakao.web.dto.WebListCardItemDto;
import com.ex.chatbot.kakao.web.dto.WebQuickReplyDto;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* SkillResponse에서 웹 채팅용 텍스트와 ListCard UI 데이터를 분리 추출한다.
*/
@Component
public class SkillResponseWebExtractor {
public WebExtractResult extract(SkillResponse response) {
if (response == null || response.getTemplate() == null) {
return WebExtractResult.empty();
}
List<Template> outputs = response.getTemplate().getOutputs();
if (outputs == null || outputs.isEmpty()) {
return WebExtractResult.empty();
}
List<String> textParts = new ArrayList<>();
List<WebListCardDto> listCards = new ArrayList<>();
List<WebActionButtonDto> actionButtons = new ArrayList<>();
Set<String> seenActionButtons = new LinkedHashSet<>();
List<WebQuickReplyDto> quickReplies = new ArrayList<>(extractQuickReplies(response));
Set<String> seenQuickReplies = new LinkedHashSet<>();
for (WebQuickReplyDto quickReply : quickReplies) {
if (quickReply != null && quickReply.getMessage() != null) {
seenQuickReplies.add(quickReply.getMessage().trim());
}
}
for (Template output : outputs) {
if (output instanceof SimpleTextView) {
appendSimpleText((SimpleTextView) output, textParts);
} else if (output instanceof BasicCardView) {
appendBasicCardText((BasicCardView) output, textParts, actionButtons, seenActionButtons,
quickReplies, seenQuickReplies);
} else if (output instanceof ListCardView) {
appendListCard((ListCardView) output, listCards, actionButtons, seenActionButtons,
quickReplies, seenQuickReplies);
}
}
String answer = String.join("\n\n", textParts).trim();
Map<String, Object> fareBreakdown = extractFareBreakdown(response);
if (fareBreakdown != null && !listCards.isEmpty()) {
for (int i = listCards.size() - 1; i >= 0; i--) {
WebListCardDto card = listCards.get(i);
if (card.getTitle() != null && card.getTitle().contains("통행요금")) {
card.setFareBreakdown(fareBreakdown);
break;
}
}
}
return new WebExtractResult(answer, listCards, quickReplies, actionButtons);
}
@SuppressWarnings("unchecked")
private Map<String, Object> extractFareBreakdown(SkillResponse response) {
if (response == null || response.getData() == null) {
return null;
}
Object value = response.getData().get("fareBreakdown");
if (value instanceof Map) {
return (Map<String, Object>) value;
}
return null;
}
private List<WebQuickReplyDto> extractQuickReplies(SkillResponse response) {
if (response.getTemplate() == null || response.getTemplate().getQuickReplies() == null) {
return Collections.emptyList();
}
List<WebQuickReplyDto> result = new ArrayList<>();
Set<String> seen = new LinkedHashSet<>();
for (QuickReply quickReply : response.getTemplate().getQuickReplies()) {
if (quickReply == null) {
continue;
}
if (quickReply.getAction() != null && !"message".equals(quickReply.getAction())) {
continue;
}
String message = quickReply.getMessageText();
if (message == null || message.isBlank()) {
message = quickReply.getLabel();
}
if (message == null || message.isBlank()) {
continue;
}
String normalized = message.trim();
if (!seen.add(normalized)) {
continue;
}
String label = quickReply.getLabel();
if (label == null || label.isBlank()) {
label = normalized;
}
result.add(WebQuickReplyDto.builder()
.label(label.trim())
.message(normalized)
.build());
if (result.size() >= 10) {
break;
}
}
return result;
}
private void appendSimpleText(SimpleTextView view, List<String> parts) {
SimpleText simpleText = view.getSimpleText();
if (simpleText != null && simpleText.getText() != null && !simpleText.getText().isBlank()) {
parts.add(simpleText.getText().trim());
}
}
private void appendBasicCardText(
BasicCardView view,
List<String> parts,
List<WebActionButtonDto> actionButtons,
Set<String> seenActionButtons,
List<WebQuickReplyDto> quickReplies,
Set<String> seenQuickReplies
) {
BasicCard basicCard = view.getBasicCard();
if (basicCard == null) {
return;
}
if (basicCard.getTitle() != null && !basicCard.getTitle().isBlank()) {
parts.add(basicCard.getTitle().trim());
}
if (basicCard.getDescription() != null && !basicCard.getDescription().isBlank()) {
parts.add(basicCard.getDescription().trim());
}
appendButtons(basicCard.getButtons(), actionButtons, seenActionButtons, quickReplies, seenQuickReplies);
}
private void appendListCard(
ListCardView view,
List<WebListCardDto> listCards,
List<WebActionButtonDto> actionButtons,
Set<String> seenActionButtons,
List<WebQuickReplyDto> quickReplies,
Set<String> seenQuickReplies
) {
ListCard listCard = view.getListCard();
if (listCard == null) {
return;
}
String title = null;
String headerImageUrl = null;
Item header = listCard.getHeader();
if (header != null) {
if (header.getTitle() != null && !header.getTitle().isBlank()) {
title = header.getTitle().trim();
}
headerImageUrl = header.getImageUrl();
}
List<WebListCardItemDto> items = new ArrayList<>();
List<Item> rawItems = listCard.getItems();
if (rawItems != null) {
for (Item item : rawItems) {
if (item == null) {
continue;
}
String itemTitle = item.getTitle() != null ? item.getTitle().trim() : "";
String itemDesc = item.getDescription() != null ? item.getDescription().trim() : "";
if (itemTitle.isBlank() && itemDesc.isBlank()) {
continue;
}
items.add(WebListCardItemDto.builder()
.title(itemTitle)
.description(itemDesc)
.imageUrl(item.getImageUrl())
.linkUrl(primaryLinkUrl(item.getLink()))
.build());
}
}
if (title == null && items.isEmpty()) {
return;
}
listCards.add(WebListCardDto.builder()
.title(title)
.headerImageUrl(headerImageUrl)
.items(items)
.build());
appendButtons(listCard.getButtons(), actionButtons, seenActionButtons, quickReplies, seenQuickReplies);
}
private void appendButtons(
List<Button> buttons,
List<WebActionButtonDto> actionButtons,
Set<String> seenActionButtons,
List<WebQuickReplyDto> quickReplies,
Set<String> seenQuickReplies
) {
if (buttons == null || buttons.isEmpty()) {
return;
}
for (Button button : buttons) {
if (button == null) {
continue;
}
WebActionButtonDto actionButton = toWebActionButton(button);
if (actionButton != null) {
String key = (actionButton.getAction() == null ? "" : actionButton.getAction()) + "|"
+ (actionButton.getLabel() == null ? "" : actionButton.getLabel()) + "|"
+ (actionButton.getUrl() == null ? "" : actionButton.getUrl()) + "|"
+ (actionButton.getPhoneNumber() == null ? "" : actionButton.getPhoneNumber());
if (seenActionButtons.add(key)) {
actionButtons.add(actionButton);
}
continue;
}
appendMessageButtonAsQuickReply(button, quickReplies, seenQuickReplies);
}
}
private WebActionButtonDto toWebActionButton(Button button) {
if (button instanceof WebLinkButton) {
WebLinkButton webLink = (WebLinkButton) button;
String url = trimToNull(webLink.getWebLinkUrl());
if (url == null) {
return null;
}
return WebActionButtonDto.builder()
.label(defaultLabel(webLink.getLabel(), "바로가기"))
.action("webLink")
.url(url)
.webUrl(url)
.build();
}
if (button instanceof OsLinkButton) {
OsLinkButton osLinkButton = (OsLinkButton) button;
Link link = osLinkButton.getOsLink();
if (link == null) {
return null;
}
String pc = trimToNull(link.getPc());
String web = trimToNull(link.getWeb());
String mobile = trimToNull(link.getMobile());
String android = trimToNull(link.getAndroid());
String ios = trimToNull(link.getIos());
String primary = firstNonBlank(pc, web, mobile, android, ios);
if (primary == null) {
return null;
}
return WebActionButtonDto.builder()
.label(defaultLabel(osLinkButton.getLabel(), "바로가기"))
.action("osLink")
.url(primary)
.pcUrl(pc)
.webUrl(web)
.mobileUrl(mobile)
.androidUrl(android)
.iosUrl(ios)
.build();
}
if (button instanceof PhoneButton) {
PhoneButton phone = (PhoneButton) button;
String phoneNumber = trimToNull(phone.getPhoneNumber());
if (phoneNumber == null) {
return null;
}
return WebActionButtonDto.builder()
.label(defaultLabel(phone.getLabel(), "전화하기"))
.action("phone")
.phoneNumber(phoneNumber)
.url("tel:" + phoneNumber.replaceAll("[^0-9+]", ""))
.build();
}
return null;
}
private void appendMessageButtonAsQuickReply(
Button button,
List<WebQuickReplyDto> quickReplies,
Set<String> seenQuickReplies
) {
String label = null;
String message = null;
if (button instanceof MessageButton) {
MessageButton messageButton = (MessageButton) button;
label = messageButton.getLabel();
message = messageButton.getMessageText();
} else if (button instanceof BlockButton) {
BlockButton blockButton = (BlockButton) button;
label = blockButton.getLabel();
message = blockButton.getMessageText();
}
message = trimToNull(message);
if (message == null) {
message = trimToNull(label);
}
if (message == null || !seenQuickReplies.add(message)) {
return;
}
quickReplies.add(WebQuickReplyDto.builder()
.label(defaultLabel(label, message))
.message(message)
.build());
}
private String defaultLabel(String label, String fallback) {
String trimmed = trimToNull(label);
return trimmed != null ? trimmed : fallback;
}
private String firstNonBlank(String... values) {
for (String value : values) {
String trimmed = trimToNull(value);
if (trimmed != null) {
return trimmed;
}
}
return null;
}
private String primaryLinkUrl(Link link) {
if (link == null) {
return null;
}
return firstNonBlank(link.getPc(), link.getWeb(), link.getMobile(), link.getAndroid(), link.getIos());
}
private String trimToNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
public static final class WebExtractResult {
private final String answer;
private final List<WebListCardDto> listCards;
private final List<WebQuickReplyDto> quickReplies;
private final List<WebActionButtonDto> actionButtons;
public WebExtractResult(
String answer,
List<WebListCardDto> listCards,
List<WebQuickReplyDto> quickReplies,
List<WebActionButtonDto> actionButtons
) {
this.answer = answer != null ? answer : "";
this.listCards = listCards != null ? listCards : Collections.emptyList();
this.quickReplies = quickReplies != null ? quickReplies : Collections.emptyList();
this.actionButtons = actionButtons != null ? actionButtons : Collections.emptyList();
}
public static WebExtractResult empty() {
return new WebExtractResult("", Collections.emptyList(), Collections.emptyList(), Collections.emptyList());
}
public String getAnswer() {
return answer;
}
public List<WebListCardDto> getListCards() {
return listCards;
}
public List<WebQuickReplyDto> getQuickReplies() {
return quickReplies;
}
public List<WebActionButtonDto> getActionButtons() {
return actionButtons;
}
}
}
@@ -0,0 +1,265 @@
package com.ex.chatbot.kakao.web;
import com.ex.chatbot.kakao.config.AgentProperties;
import com.ex.chatbot.kakao.service.DataPortalService;
import com.ex.chatbot.kakao.service.RagAgentClient;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import javax.annotation.PostConstruct;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Slf4j
@Service
@RequiredArgsConstructor
public class WebChatAgentService {
private final AgentProperties agentProperties;
private final RagAgentClient ragAgentClient;
private final DataPortalService dataPortalService;
private final RestTemplate restTemplate;
@Value("${chatbot-api.base-url:http://172.16.180.130:8086/api/}")
private String chatbotApiBaseUrl;
@PostConstruct
public void init() {
chatbotApiBaseUrl = normalizeBaseUrl(chatbotApiBaseUrl);
log.info("[Web Chat] chatbotApi base-url: {}", chatbotApiBaseUrl);
}
public Map<String, Object> ask(String userInput, String botId) throws Exception {
if (agentProperties.isAgentMode()) {
try {
return ragAgentClient.chat(userInput, botId);
} catch (Exception e) {
log.warn("[Web Chat][Agent] /agent/chat 실패 - fallbackLegacy={}", agentProperties.isFallbackLegacy(), e);
if (agentProperties.isFallbackLegacy()) {
return callLegacyLlmAgentApi(userInput, botId);
}
throw e;
}
}
return callLegacyLlmAgentApi(userInput, botId);
}
@SuppressWarnings("unchecked")
private Map<String, Object> callLegacyLlmAgentApi(String userInput, String botId) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
Map<String, Object> body = new HashMap<>();
body.put("question", userInput);
body.put("botId", botId);
String llmAgentUrl = chatbotApiUrl("v1/llm-agent");
Map<String, Object> response = restTemplate.postForObject(
llmAgentUrl,
new HttpEntity<>(body, headers),
Map.class
);
log.info("[Web Chat][Legacy] 응답 수신 - routeType: {}, intentType: {}, uiType: {}",
response != null ? response.get("routeType") : null,
response != null ? response.get("intentType") : null,
response != null ? response.get("uiType") : null);
if (response == null) {
Map<String, Object> fallback = new HashMap<>();
fallback.put("answer", "죄송합니다. 답변을 생성하지 못했습니다.");
return fallback;
}
if ("web_fetch_required".equals(response.get("routeType"))) {
return fetchWebDomainDataAndCallAgent(userInput, botId, response);
}
return response;
}
@SuppressWarnings("unchecked")
private Map<String, Object> fetchWebDomainDataAndCallAgent(
String userInput,
String botId,
Map<String, Object> routeResponse
) {
String intentType = String.valueOf(routeResponse.get("intentType"));
Map<String, Object> params = routeResponse.get("params") instanceof Map
? (Map<String, Object>) routeResponse.get("params")
: new HashMap<>();
log.info("[Web Chat][Domain Fetch] intentType: {}, params: {}", intentType, params);
Map<String, Object> domainData = fetchWebDomainData(intentType, params);
domainData = refineGasstationDirectionFromUserInput(intentType, params, userInput, domainData);
if (domainData == null || domainData.isEmpty()) {
Map<String, Object> fallback = new HashMap<>();
fallback.put("answer", "죄송합니다. 조회할 수 있는 정보를 찾지 못했습니다.");
fallback.put("intentType", intentType);
fallback.put("params", params);
fallback.put("domainData", domainData);
return fallback;
}
return callAgentApiWithDomainData(userInput, botId, intentType, params, domainData, routeResponse);
}
@SuppressWarnings("unchecked")
private Map<String, Object> refineGasstationDirectionFromUserInput(
String intentType,
Map<String, Object> params,
String userInput,
Map<String, Object> domainData
) {
if (!"GASSTATION".equals(intentType)
|| params.get("restAreaDirection") != null
|| domainData == null
|| !Boolean.TRUE.equals(domainData.get("bidirectional"))) {
return domainData;
}
Object rawDirections = domainData.get("directions");
if (!(rawDirections instanceof List)) {
return domainData;
}
String normalizedInput = normalizeDirectionText(userInput);
for (Object directionObj : (List<Object>) rawDirections) {
String direction = valueAsString(directionObj);
if (direction == null) {
continue;
}
if (normalizedInput.contains(normalizeDirectionText(direction))) {
params.put("restAreaDirection", direction);
log.info("[Web Chat][Domain Fetch] 사용자 발화에서 주유소 방향 보정 - direction: {}", direction);
return fetchWebDomainData(intentType, params);
}
}
return domainData;
}
private Map<String, Object> fetchWebDomainData(String intentType, Map<String, Object> params) {
String restAreaName = valueAsString(params.get("restAreaName"));
if ("GASSTATION".equals(intentType)) {
return dataPortalService.getGasstationInfo(
restAreaName,
valueAsString(params.get("restAreaDirection"))
);
}
if ("BRAND_SHOP".equals(intentType)) {
return dataPortalService.getBrandShopList(
restAreaName,
valueAsString(params.get("brand")),
valueAsInteger(params.get("startIndex"))
);
}
if ("REST_AREA_FOOD_LIST".equals(intentType)) {
return dataPortalService.getFoodList(restAreaName);
}
Map<String, Object> unsupported = new HashMap<>();
unsupported.put("status", false);
unsupported.put("statusMsg", "지원하지 않는 외부 데이터 조회 유형입니다: " + intentType);
return unsupported;
}
@SuppressWarnings("unchecked")
private Map<String, Object> callAgentApiWithDomainData(
String userInput,
String botId,
String intentType,
Map<String, Object> params,
Map<String, Object> domainData,
Map<String, Object> routeResponse
) {
try {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
Map<String, Object> body = new HashMap<>();
body.put("question", userInput);
body.put("botId", botId);
body.put("intentType", intentType);
body.put("params", params);
body.put("domainData", domainData);
Map<String, Object> agentResponse = restTemplate.postForObject(
chatbotApiUrl("v1/agent"),
new HttpEntity<>(body, headers),
Map.class
);
if (agentResponse == null) {
Map<String, Object> fallback = new HashMap<>();
fallback.put("answer", "죄송합니다. 답변을 생성하지 못했습니다.");
return fallback;
}
agentResponse.put("answer", agentResponse.get("llmAnswer"));
agentResponse.put("intentType", intentType);
agentResponse.put("params", params);
agentResponse.put("domainData", agentResponse.get("domainData") != null ? agentResponse.get("domainData") : domainData);
agentResponse.put("routeType", "web_domain");
agentResponse.put("uiType", routeResponse.get("uiType"));
return agentResponse;
} catch (Exception e) {
log.error("[Web Chat][Domain Fetch] /api/v1/agent 재호출 실패", e);
Map<String, Object> fallback = new HashMap<>();
fallback.put("answer", "죄송합니다. 서비스에 일시적인 장애가 발생하였습니다.");
fallback.put("intentType", intentType);
fallback.put("params", params);
fallback.put("domainData", domainData);
return fallback;
}
}
private String chatbotApiUrl(String path) {
return chatbotApiBaseUrl + path;
}
private String normalizeBaseUrl(String baseUrl) {
if (baseUrl == null || baseUrl.trim().isEmpty()) {
return "http://172.16.180.130:8086/api/";
}
String trimmed = baseUrl.trim();
return trimmed.endsWith("/") ? trimmed : trimmed + "/";
}
private String normalizeDirectionText(Object value) {
String text = valueAsString(value);
if (text == null) {
return "";
}
return text.replaceAll("\\s+", "")
.replace("방향", "")
.replace("행", "");
}
private String valueAsString(Object value) {
if (value == null) {
return null;
}
String text = String.valueOf(value).trim();
return text.isEmpty() || "null".equalsIgnoreCase(text) ? null : text;
}
private Integer valueAsInteger(Object value) {
if (value instanceof Number) {
return ((Number) value).intValue();
}
String text = valueAsString(value);
if (text == null) {
return null;
}
try {
return Integer.valueOf(text);
} catch (NumberFormatException e) {
return null;
}
}
}
@@ -0,0 +1,81 @@
package com.ex.chatbot.kakao.web;
import com.ex.chatbot.kakao.web.dto.WebAskRequest;
import com.ex.chatbot.kakao.web.dto.WebAskResponse;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
@Slf4j
@RestController
@RequestMapping("/web")
@RequiredArgsConstructor
public class WebChatController {
private static final long SSE_TIMEOUT_MS = 120_000L;
private final WebChatService webChatService;
private final ObjectMapper objectMapper;
@PostMapping("/ask")
public WebAskResponse ask(@RequestBody WebAskRequest request) {
return webChatService.ask(request);
}
@PostMapping(value = "/ask/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter askStream(@RequestBody WebAskRequest request) {
SseEmitter emitter = new SseEmitter(SSE_TIMEOUT_MS);
CompletableFuture.runAsync(() -> {
try {
emitter.send(SseEmitter.event()
.name("status")
.data("{\"phase\":\"generating\",\"message\":\"답변 생성 중…\"}"));
WebAskResponse response = webChatService.ask(request);
emitter.send(SseEmitter.event()
.name("done")
.data(objectMapper.writeValueAsString(response)));
emitter.complete();
} catch (Exception e) {
log.error("[Web Chat] SSE 처리 오류", e);
try {
emitter.send(SseEmitter.event()
.name("error")
.data("{\"message\":\"서비스에 일시적인 장애가 발생하였습니다.\"}"));
} catch (Exception ignored) {
// ignore secondary failure
}
emitter.completeWithError(e);
}
});
return emitter;
}
@GetMapping(value = "/openapi.json", produces = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Object> openApiSpec() {
Map<String, Object> askStreamPost = Map.of("post", Map.of("summary", "Web chat ask stream"));
Map<String, Object> askPost = Map.of("post", Map.of("summary", "Web chat ask"));
Map<String, Object> paths = new LinkedHashMap<>();
paths.put("/ask/stream", askStreamPost);
paths.put("/ask", askPost);
Map<String, Object> spec = new LinkedHashMap<>();
spec.put("openapi", "3.0.0");
spec.put("info", Map.of("title", "kakaoChatbotSkill Web API", "version", "1.0.0"));
spec.put("paths", paths);
return spec;
}
}
@@ -0,0 +1,610 @@
package com.ex.chatbot.kakao.web;
import com.ex.chatbot.kakao.web.dto.WebActionButtonDto;
import com.ex.chatbot.kakao.web.dto.WebAskResponse;
import com.ex.chatbot.kakao.web.dto.WebListCardDto;
import com.ex.chatbot.kakao.web.dto.WebListCardItemDto;
import com.ex.chatbot.kakao.web.dto.WebQuickReplyDto;
import org.springframework.stereotype.Component;
import java.io.UnsupportedEncodingException;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@Component
public class WebChatResponseMapper {
private static final String FARELIST_PLAIN_URL = "https://chatbot.ex.co.kr/images/kakao/default/blackBanner.png";
private static final String FARELIST_1 = "https://chatbot.ex.co.kr/images/kakao/fare/1_type.png";
private static final String FARELIST_2 = "https://chatbot.ex.co.kr/images/kakao/fare/2_type.png";
private static final String FARELIST_3 = "https://chatbot.ex.co.kr/images/kakao/fare/3_type.png";
private static final String FARELIST_4 = "https://chatbot.ex.co.kr/images/kakao/fare/4_type.png";
private static final String FARELIST_5 = "https://chatbot.ex.co.kr/images/kakao/fare/5_type.png";
private static final String RESTAREA_LIST_BANNER_URL = "https://chatbot.ex.co.kr/images/kakao/default/blackBanner.png";
private static final String GASSTATION_GASOLINE_URL = "https://chatbot.ex.co.kr/images/kakao/restArea/gasoline.png";
private static final String GASSTATION_DIESEL_URL = "https://chatbot.ex.co.kr/images/kakao/restArea/diesel.png";
private static final String GASSTATION_LPG_URL = "https://chatbot.ex.co.kr/images/kakao/restArea/lpg.png";
private static final String BRAND_IMAGE_URL = "https://chatbot.ex.co.kr/images/logo/";
private static final int LISTCARD_MAX = 5;
private static final int FOOD_MENU_DISPLAY_LIMIT = 8;
public WebAskResponse toResponse(Map<String, Object> raw, String botId) {
Map<String, Object> response = raw != null ? raw : Collections.emptyMap();
List<WebListCardDto> listCards = new ArrayList<>();
List<WebQuickReplyDto> quickReplies = new ArrayList<>();
List<WebActionButtonDto> actionButtons = new ArrayList<>();
appendRawQuickReplies(quickReplies, response);
appendIcCandidateQuickReplies(quickReplies, response);
appendFaqActionButtons(actionButtons, response);
appendDomainUi(listCards, quickReplies, actionButtons, response);
String answer = answerOf(response);
if (answer.isBlank() && listCards.isEmpty() && actionButtons.isEmpty() && quickReplies.isEmpty()) {
answer = "죄송합니다. 답변을 생성하지 못했습니다.";
}
List<Map<String, Object>> references = referencesOf(response);
Map<String, Object> domainData = mapOf(response.get("domainData"));
Map<String, Object> params = mapOf(response.get("params"));
return WebAskResponse.builder()
.status(true)
.answer(answer)
.botId(botId)
.routeType(valueAsString(response.get("routeType")))
.intentType(valueAsString(response.get("intentType")))
.uiType(valueAsString(response.get("uiType")))
.references(references)
.num_references(references.size())
.params(params)
.domainData(domainData)
.rawData(new HashMap<>(response))
.listCards(listCards)
.quickReplies(quickReplies)
.actionButtons(actionButtons)
.build();
}
private String answerOf(Map<String, Object> response) {
Object answerObj = response.get("answer") != null ? response.get("answer") : response.get("llmAnswer");
if (answerObj == null) {
return "";
}
return String.valueOf(answerObj)
.replaceAll(" \\n", "\n")
.replaceAll("\\n{3,}", "\n\n")
.replaceAll(" *\\n *", "\n")
.trim();
}
@SuppressWarnings("unchecked")
private List<Map<String, Object>> referencesOf(Map<String, Object> response) {
Object refs = response.get("references");
if (!(refs instanceof List)) {
return Collections.emptyList();
}
List<Map<String, Object>> result = new ArrayList<>();
for (Object ref : (List<Object>) refs) {
if (ref instanceof Map) {
result.add((Map<String, Object>) ref);
}
}
return result;
}
private void appendDomainUi(
List<WebListCardDto> listCards,
List<WebQuickReplyDto> quickReplies,
List<WebActionButtonDto> actionButtons,
Map<String, Object> response
) {
String intentType = valueAsString(response.get("intentType"));
String uiType = valueAsString(response.get("uiType"));
if ("FARE_SEARCH".equals(intentType) || "FARE_CARD".equals(uiType)) {
appendFareSearch(listCards, quickReplies, response);
return;
}
if ("FARE_UNPAID".equals(intentType)) {
appendUnpaidActions(actionButtons, response);
return;
}
if ("FARE_REFUND".equals(intentType)) {
appendRefundActions(actionButtons, response);
return;
}
if ("IC_TEL".equals(intentType) || "PHONE_CARD".equals(uiType)) {
appendIcTelAction(actionButtons, response);
return;
}
if ("GASSTATION".equals(intentType) || "GASSTATION_CARD".equals(uiType)) {
appendGasstationCards(listCards, response);
return;
}
if ("BRAND_SHOP".equals(intentType) || "BRAND_SHOP_CARD".equals(uiType)) {
appendBrandShopCards(listCards, response);
return;
}
if ("REST_AREA_FOOD_LIST".equals(intentType) || "FOOD_LIST_CARD".equals(uiType)) {
appendFoodCards(listCards, response);
return;
}
if ("REST_AREA_PRICE".equals(intentType) || "PRICE_CARD".equals(uiType)) {
appendRestAreaPriceCards(listCards, response);
return;
}
if ("TROAD_SEARCH_ROUTE".equals(intentType)) {
appendTroadSearchRouteCandidates(quickReplies, response);
return;
}
if ("TROAD_CONGESTION".equals(intentType)) {
appendRouteCandidateQuickReplies(quickReplies, response, "정체정보");
return;
}
if ("TROAD_BREAKING_NEWS".equals(intentType)) {
appendRouteCandidateQuickReplies(quickReplies, response, "교통속보");
}
}
private void appendFaqActionButtons(List<WebActionButtonDto> actionButtons, Map<String, Object> response) {
Object rawUrls = response.get("faqUrls");
if (!(rawUrls instanceof List)) {
return;
}
Set<String> seen = new LinkedHashSet<>();
for (Object rawUrl : (List<?>) rawUrls) {
String url = valueAsString(rawUrl);
if (!isHttpUrl(url) || !seen.add(url)) {
continue;
}
actionButtons.add(webLink(seen.size() == 1 ? "FAQ 원문 보기" : "FAQ 원문 " + seen.size(), url));
if (seen.size() >= 3) {
break;
}
}
}
@SuppressWarnings("unchecked")
private void appendRawQuickReplies(List<WebQuickReplyDto> quickReplies, Map<String, Object> response) {
Object rawQuickReplies = response.get("quickReplies");
if (!(rawQuickReplies instanceof List)) {
return;
}
Set<String> existingMessages = quickReplies.stream()
.map(WebQuickReplyDto::getMessage)
.collect(Collectors.toSet());
for (Object rawQuickReply : (List<Object>) rawQuickReplies) {
String label = null;
String message = null;
if (rawQuickReply instanceof Map) {
Map<String, Object> quickReplyMap = (Map<String, Object>) rawQuickReply;
label = firstNonBlank(
valueAsString(quickReplyMap.get("label")),
valueAsString(quickReplyMap.get("title")),
valueAsString(quickReplyMap.get("message")),
valueAsString(quickReplyMap.get("messageText"))
);
message = firstNonBlank(
valueAsString(quickReplyMap.get("message")),
valueAsString(quickReplyMap.get("messageText")),
label
);
} else {
label = valueAsString(rawQuickReply);
message = label;
}
if (label == null || message == null || !existingMessages.add(message)) {
continue;
}
quickReplies.add(quickReply(label, message));
}
}
@SuppressWarnings("unchecked")
private void appendIcCandidateQuickReplies(List<WebQuickReplyDto> quickReplies, Map<String, Object> response) {
Object rawCandidates = response.get("icCandidates");
if (!(rawCandidates instanceof List)) {
return;
}
Set<String> existingLabels = quickReplies.stream()
.map(WebQuickReplyDto::getLabel)
.collect(Collectors.toSet());
int insertIndex = 0;
for (Object candidateObj : (List<Object>) rawCandidates) {
if (!(candidateObj instanceof Map)) {
continue;
}
String icName = valueAsString(((Map<String, Object>) candidateObj).get("icName"));
if (icName == null || existingLabels.contains(icName)) {
continue;
}
quickReplies.add(insertIndex++, quickReply(icName, icName));
existingLabels.add(icName);
}
}
private void appendFareSearch(
List<WebListCardDto> listCards,
List<WebQuickReplyDto> quickReplies,
Map<String, Object> response
) {
Map<String, Object> domainData = mapOf(response.get("domainData"));
Map<String, Object> fareData = mapOf(domainData.get("fareData"));
String fareType = valueAsString(fareData.get("type"));
if ("open_from".equals(fareType)) {
appendOpenFareQuickReplies(quickReplies, fareData, true);
return;
}
if ("open_to".equals(fareType)) {
appendOpenFareQuickReplies(quickReplies, fareData, false);
return;
}
if (!"exact".equals(fareType)) {
return;
}
Map<String, Object> params = mapOf(response.get("params"));
String fromIc = params.get("fromIc") != null ? String.valueOf(params.get("fromIc")) : "출발지";
String toIc = params.get("toIc") != null ? String.valueOf(params.get("toIc")) : "도착지";
List<WebListCardItemDto> items = new ArrayList<>();
items.add(item("1종 : " + toInt(fareData.get("fare1")) + "원\n* 1종(경차) : " + toInt(fareData.get("fare1Small")) + "원",
"승용차, 소형승합차/화물차, 경차", FARELIST_1));
items.add(item("2종 : " + toInt(fareData.get("fare2")) + "원", "중형승합차, 중형화물차", FARELIST_2));
items.add(item("3종 : " + toInt(fareData.get("fare3")) + "원", "대형승합차, 2축 대형화물차", FARELIST_3));
items.add(item("4종 : " + toInt(fareData.get("fare4")) + "원", "3축 대형화물차", FARELIST_4));
items.add(item("5종 : " + toInt(fareData.get("fare5")) + "원", "4축 이상 특수 화물차", FARELIST_5));
listCards.add(WebListCardDto.builder()
.title(fromIc + " - " + toIc + " 통행요금입니다.")
.headerImageUrl(FARELIST_PLAIN_URL)
.items(items)
.fareBreakdown(mapOf(fareData.get("webBreakdown")))
.build());
}
@SuppressWarnings("unchecked")
private void appendOpenFareQuickReplies(List<WebQuickReplyDto> quickReplies, Map<String, Object> fareData, boolean openFrom) {
Object outIcsObj = fareData.get("outIcs");
if (!(outIcsObj instanceof List)) {
return;
}
String fixedIc = openFrom ? valueAsString(fareData.get("toIc")) : valueAsString(fareData.get("fromIc"));
if (fixedIc == null) {
return;
}
int insertIndex = 0;
for (String outIc : (List<String>) outIcsObj) {
if (outIc == null || outIc.trim().isEmpty()) {
continue;
}
String label = openFrom ? outIc + "에서 " + fixedIc : fixedIc + "에서 " + outIc;
quickReplies.add(insertIndex++, quickReply(label, label + " 통행요금"));
}
}
private void appendUnpaidActions(List<WebActionButtonDto> actionButtons, Map<String, Object> response) {
Map<String, Object> domainData = mapOf(response.get("domainData"));
if (!Boolean.TRUE.equals(domainData.get("status")) || toLong(domainData.get("sumAmt")) < 100) {
return;
}
String carNo = valueAsString(domainData.get("carNo"));
if (carNo == null) {
carNo = valueAsString(mapOf(response.get("params")).get("carNo"));
}
if (carNo == null) {
return;
}
String noMemberUrl = "https://www.hipass.co.kr/nomem/NoMemberNopaySearch.do?car_no=" + carNo
+ "&number=" + carNo + "&gubun=chatbot";
actionButtons.add(osLink("비회원 미납 납부", noMemberUrl, noMemberUrl, noMemberUrl));
actionButtons.add(osLink("고속도로 통행료 앱",
"https://m.hipass.co.kr/runApp.jsp",
"intent://details?id=com.ex.hipass_app#Intent;scheme=market;package=com.android.vending;end",
"exhipassapp://open?url=/noMemUnpaidToll/noMemUnpaidTollCert.do?carNo=" + carNo));
actionButtons.add(osLink("고속도로 통행료 홈페이지",
"https://www.hipass.co.kr/comm/lginpg.do",
"https://www.hipass.co.kr/comm/lginpg.do",
"https://www.hipass.co.kr/comm/lginpg.do"));
}
private void appendRefundActions(List<WebActionButtonDto> actionButtons, Map<String, Object> response) {
Map<String, Object> domainData = mapOf(response.get("domainData"));
if (!Boolean.TRUE.equals(domainData.get("status"))) {
return;
}
String carNo = valueAsString(domainData.get("carNo"));
if (carNo == null) {
carNo = valueAsString(mapOf(response.get("params")).get("carNo"));
}
if (carNo == null) {
carNo = "";
}
actionButtons.add(osLink("환불 통행료 확인하기",
"https://www.hipass.co.kr/nopay/SearchRepayApply.do#mab",
"https://www.hipass.co.kr/nopay/SearchRepayApply.do#mab",
"https://www.hipass.co.kr/nopay/SearchRepayApply.do#mab"));
actionButtons.add(osLink("고속도로 통행료 앱",
"https://m.hipass.co.kr/runApp.jsp",
"intent://details?id=com.ex.hipass_app#Intent;scheme=market;package=com.android.vending;end",
"exhipassapp://open?url=/noMemUnpaidToll/noMemUnpaidTollCert.do?carNo=" + carNo));
}
private void appendIcTelAction(List<WebActionButtonDto> actionButtons, Map<String, Object> response) {
Map<String, Object> domainData = mapOf(response.get("domainData"));
if (!Boolean.TRUE.equals(domainData.get("status"))) {
return;
}
String tel = valueAsString(domainData.get("tel"));
if (tel == null) {
return;
}
actionButtons.add(WebActionButtonDto.builder()
.label("전화하기")
.action("phone")
.phoneNumber(tel)
.url("tel:" + tel.replaceAll("[^0-9+]", ""))
.build());
}
@SuppressWarnings("unchecked")
private void appendGasstationCards(List<WebListCardDto> listCards, Map<String, Object> response) {
Map<String, Object> domainData = mapOf(response.get("domainData"));
if (!Boolean.TRUE.equals(domainData.get("status")) || !(domainData.get("list") instanceof List)) {
return;
}
for (Map<String, Object> gasInfo : (List<Map<String, Object>>) domainData.get("list")) {
String gasDirection = gasstationDisplayName(domainData, gasInfo);
List<WebListCardItemDto> items = new ArrayList<>();
items.add(item(priceOrNone(gasInfo.get("gasolinePrice")), gasDirection, GASSTATION_GASOLINE_URL));
items.add(item(priceOrNone(gasInfo.get("diselPrice")), gasDirection, GASSTATION_DIESEL_URL));
items.add(item("Y".equals(String.valueOf(gasInfo.getOrDefault("lpgYn", "N")))
? String.valueOf(gasInfo.getOrDefault("lpgPrice", "X"))
: "미운영", gasDirection, GASSTATION_LPG_URL));
listCards.add(WebListCardDto.builder()
.title(gasDirection + " 주유정보입니다.")
.headerImageUrl(RESTAREA_LIST_BANNER_URL)
.items(items)
.build());
}
}
@SuppressWarnings("unchecked")
private void appendBrandShopCards(List<WebListCardDto> listCards, Map<String, Object> response) {
Map<String, Object> domainData = mapOf(response.get("domainData"));
if (!Boolean.TRUE.equals(domainData.get("status")) || !(domainData.get("list") instanceof List)) {
return;
}
List<Map<String, Object>> shops = (List<Map<String, Object>>) domainData.get("list");
String restArea = String.valueOf(domainData.getOrDefault("restAreaName", "휴게소"));
String brand = valueAsString(domainData.get("brand"));
int count = shops.size();
int listCardNum = count > LISTCARD_MAX * 3 ? 3 : count / LISTCARD_MAX + (count % LISTCARD_MAX == 0 ? 0 : 1);
String title = brand != null ? restArea + " 휴게소 " + brand + " 매장 정보입니다." : restArea + " 휴게소 매장 정보입니다.";
for (int i = 0; i < listCardNum; i++) {
List<WebListCardItemDto> items = new ArrayList<>();
int endNum = Math.min((i + 1) * LISTCARD_MAX, count);
for (int j = i * LISTCARD_MAX; j < endNum; j++) {
Map<String, Object> shop = shops.get(j);
String description = shop.get("stime") + "~" + shop.get("etime") + " "
+ String.valueOf(shop.get("stdRestNm")).replace("휴게소", "");
items.add(item(String.valueOf(shop.get("brdName")), description, BRAND_IMAGE_URL + shop.get("brdCode") + ".png"));
}
listCards.add(WebListCardDto.builder().title(title).headerImageUrl(RESTAREA_LIST_BANNER_URL).items(items).build());
}
}
@SuppressWarnings("unchecked")
private void appendFoodCards(List<WebListCardDto> listCards, Map<String, Object> response) {
Map<String, Object> domainData = mapOf(response.get("domainData"));
Object msgMapObj = domainData.get("msgMap");
if (!Boolean.TRUE.equals(domainData.get("status")) || !(msgMapObj instanceof Map)) {
return;
}
for (Map.Entry<String, String> entry : ((Map<String, String>) msgMapObj).entrySet()) {
List<WebListCardItemDto> items = Arrays.stream((entry.getValue() != null ? entry.getValue() : "").split("\\r?\\n"))
.map(String::trim)
.filter(line -> !line.isEmpty())
.limit(FOOD_MENU_DISPLAY_LIMIT)
.map(line -> item(line, null, null))
.collect(Collectors.toList());
listCards.add(WebListCardDto.builder()
.title(entry.getKey() + " 주요 메뉴입니다.")
.items(items)
.build());
}
}
@SuppressWarnings("unchecked")
private void appendRestAreaPriceCards(List<WebListCardDto> listCards, Map<String, Object> response) {
Map<String, Object> domainData = mapOf(response.get("domainData"));
Object priceInfoObj = domainData.get("priceInfo");
if (!Boolean.TRUE.equals(domainData.get("status")) || !(priceInfoObj instanceof Map)) {
return;
}
DecimalFormat formatter = new DecimalFormat("#,###");
Map<String, List<Map<String, Object>>> priceInfoByRestArea = (Map<String, List<Map<String, Object>>>) priceInfoObj;
for (String restName : priceInfoByRestArea.keySet()) {
List<WebListCardItemDto> items = new ArrayList<>();
for (Map<String, Object> priceItem : priceInfoByRestArea.get(restName)) {
String snackGb = String.valueOf(priceItem.get("snackGb"));
String title = (snackGb.endsWith("류") ? snackGb : snackGb + "류");
String description = formatter.format(toInt(priceItem.get("minCost"))) + "원 / "
+ formatter.format(toInt(priceItem.get("maxCost"))) + "원 / "
+ formatter.format(toInt(priceItem.get("avgCost"))) + "원";
items.add(item(title, description, null));
}
listCards.add(WebListCardDto.builder()
.title(restName + " 음식 가격입니다. (최저/최고/평균)")
.items(items)
.build());
}
}
@SuppressWarnings("unchecked")
private void appendRouteCandidateQuickReplies(List<WebQuickReplyDto> quickReplies, Map<String, Object> response, String querySuffix) {
Map<String, Object> domainData = mapOf(response.get("domainData"));
Object rawRoutes = domainData.get("RouteQuickReplyInfo");
if (!(rawRoutes instanceof List)) {
return;
}
Map<String, Object> params = mapOf(response.get("params"));
String routeName = valueAsString(params.get("routeName"));
for (Map<String, Object> route : (List<Map<String, Object>>) rawRoutes) {
String routeNameQuick = valueAsString(route.get("ROUTENM"));
if (routeNameQuick == null || routeNameQuick.equals(routeName)) {
continue;
}
String message = routeNameQuick + " " + querySuffix;
quickReplies.add(quickReply(message, message));
}
}
private void appendTroadSearchRouteCandidates(List<WebQuickReplyDto> quickReplies, Map<String, Object> response) {
Map<String, Object> domainData = mapOf(response.get("domainData"));
if (Boolean.TRUE.equals(domainData.get("status"))) {
return;
}
Map<String, Object> params = mapOf(response.get("params"));
String fromIc = valueAsString(params.get("fromIc"));
String toIc = valueAsString(params.get("toIc"));
if (fromIc == null || toIc == null) {
return;
}
if ("대구".equals(fromIc)) {
quickReplies.add(quickReply("동대구에서 " + toIc + " 교통정보", "동대구에서 " + toIc + " 교통정보"));
quickReplies.add(quickReply("서대구에서 " + toIc + " 교통정보", "서대구에서 " + toIc + " 교통정보"));
quickReplies.add(quickReply("남대구에서 " + toIc + " 교통정보", "남대구에서 " + toIc + " 교통정보"));
quickReplies.add(quickReply("북대구에서 " + toIc + " 교통정보", "북대구에서 " + toIc + " 교통정보"));
} else if ("대구".equals(toIc)) {
quickReplies.add(quickReply(fromIc + "에서 동대구 교통정보", fromIc + "에서 동대구 교통정보"));
quickReplies.add(quickReply(fromIc + "에서 서대구 교통정보", fromIc + "에서 서대구 교통정보"));
quickReplies.add(quickReply(fromIc + "에서 남대구 교통정보", fromIc + "에서 남대구 교통정보"));
quickReplies.add(quickReply(fromIc + "에서 북대구 교통정보", fromIc + "에서 북대구 교통정보"));
}
}
private WebActionButtonDto webLink(String label, String url) {
return WebActionButtonDto.builder()
.label(label)
.action("webLink")
.url(url)
.webUrl(url)
.build();
}
private WebActionButtonDto osLink(String label, String pcUrl, String androidUrl, String iosUrl) {
return WebActionButtonDto.builder()
.label(label)
.action("osLink")
.url(pcUrl)
.pcUrl(pcUrl)
.webUrl(pcUrl)
.androidUrl(androidUrl)
.iosUrl(iosUrl)
.build();
}
private WebQuickReplyDto quickReply(String label, String message) {
return WebQuickReplyDto.builder().label(label).message(message).build();
}
private WebListCardItemDto item(String title, String description, String imageUrl) {
return WebListCardItemDto.builder()
.title(title)
.description(description)
.imageUrl(imageUrl)
.build();
}
@SuppressWarnings("unchecked")
private Map<String, Object> mapOf(Object value) {
return value instanceof Map ? (Map<String, Object>) value : Collections.emptyMap();
}
private String gasstationDisplayName(Map<String, Object> domainData, Map<String, Object> gasInfo) {
String serviceAreaName = valueAsString(gasInfo.get("serviceAreaName"));
if (serviceAreaName != null) {
return serviceAreaName.replace("주유소", "").replace("휴게소", "");
}
String restAreaName = valueAsString(domainData.get("restAreaName"));
String direction = valueAsString(gasInfo.get("direction"));
if (restAreaName != null && direction != null) {
return restAreaName + "(" + direction + ")";
}
return "주유소";
}
private String priceOrNone(Object raw) {
String value = String.valueOf(raw != null ? raw : "X");
return "X".equals(value) ? "주유금액 정보 없음" : value;
}
private String valueAsString(Object value) {
if (value == null) {
return null;
}
String text = String.valueOf(value).trim();
return text.isEmpty() || "null".equalsIgnoreCase(text) ? null : text;
}
private boolean isHttpUrl(String url) {
return url != null && (url.startsWith("http://") || url.startsWith("https://"));
}
private String firstNonBlank(String... values) {
for (String value : values) {
if (value != null && !value.trim().isEmpty()) {
return value.trim();
}
}
return null;
}
private int toInt(Object value) {
if (value instanceof Number) {
return ((Number) value).intValue();
}
if (value == null) {
return 0;
}
try {
return Integer.parseInt(String.valueOf(value));
} catch (NumberFormatException e) {
return 0;
}
}
private long toLong(Object value) {
if (value instanceof Number) {
return ((Number) value).longValue();
}
if (value == null) {
return 0L;
}
try {
return Long.parseLong(String.valueOf(value));
} catch (NumberFormatException e) {
return 0L;
}
}
@SuppressWarnings("unused")
private String encodeUrl(String url) {
try {
return java.net.URLEncoder.encode(url, java.nio.charset.StandardCharsets.UTF_8.toString());
} catch (UnsupportedEncodingException e) {
return url;
}
}
}
@@ -0,0 +1,66 @@
package com.ex.chatbot.kakao.web;
import com.ex.chatbot.kakao.web.dto.WebAskRequest;
import com.ex.chatbot.kakao.web.dto.WebAskResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.Map;
@Slf4j
@Service
@RequiredArgsConstructor
public class WebChatService {
private final WebChatAgentService webChatAgentService;
private final WebChatResponseMapper responseMapper;
public WebAskResponse ask(WebAskRequest request) {
String query = request != null ? request.getQuery() : null;
String botId = request != null && request.getBotId() != null && !request.getBotId().isBlank()
? request.getBotId()
: "web-anonymous";
if (query == null || query.isBlank()) {
return WebAskResponse.builder()
.status(false)
.answer("질문 내용을 입력해주세요.")
.botId(botId)
.build();
}
log.info("[Web Chat] 질문 수신 - botId: {}, query: '{}'", botId, query);
String normalizedQuestion = normalizeQuestion(query);
try {
Map<String, Object> rawResponse = webChatAgentService.ask(normalizedQuestion, botId);
WebAskResponse response = responseMapper.toResponse(rawResponse, botId);
log.info("[Web Chat] 답변 생성 완료 - botId: {}, textLength: {}, listCards: {}, quickReplies: {}, actionButtons: {}, numReferences: {}, routeType: {}, intentType: {}",
botId,
response.getAnswer() != null ? response.getAnswer().length() : 0,
response.getListCards() != null ? response.getListCards().size() : 0,
response.getQuickReplies() != null ? response.getQuickReplies().size() : 0,
response.getActionButtons() != null ? response.getActionButtons().size() : 0,
response.getNum_references(),
response.getRouteType(),
response.getIntentType());
return response;
} catch (Exception e) {
log.error("[Web Chat] 처리 오류", e);
return WebAskResponse.builder()
.status(false)
.answer("죄송합니다. 서비스에 일시적인 장애가 발생하였습니다. 나중에 다시 시도해주세요.")
.botId(botId)
.build();
}
}
private String normalizeQuestion(String query) {
String normalized = query.trim();
if (normalized.startsWith("자체개발")) {
return normalized.substring("자체개발".length()).trim();
}
return normalized;
}
}
@@ -0,0 +1,18 @@
package com.ex.chatbot.kakao.web.dto;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class WebActionButtonDto {
private String label;
private String action;
private String url;
private String webUrl;
private String pcUrl;
private String mobileUrl;
private String androidUrl;
private String iosUrl;
private String phoneNumber;
}
@@ -0,0 +1,9 @@
package com.ex.chatbot.kakao.web.dto;
import lombok.Data;
@Data
public class WebAskRequest {
private String query;
private String botId;
}
@@ -0,0 +1,26 @@
package com.ex.chatbot.kakao.web.dto;
import lombok.Builder;
import lombok.Data;
import java.util.List;
import java.util.Map;
@Data
@Builder
public class WebAskResponse {
private boolean status;
private String answer;
private String botId;
private String routeType;
private String intentType;
private String uiType;
private Integer num_references;
private List<Map<String, Object>> references;
private Map<String, Object> params;
private Map<String, Object> domainData;
private Map<String, Object> rawData;
private List<WebListCardDto> listCards;
private List<WebQuickReplyDto> quickReplies;
private List<WebActionButtonDto> actionButtons;
}
@@ -0,0 +1,16 @@
package com.ex.chatbot.kakao.web.dto;
import lombok.Builder;
import lombok.Data;
import java.util.List;
import java.util.Map;
@Data
@Builder
public class WebListCardDto {
private String title;
private String headerImageUrl;
private List<WebListCardItemDto> items;
private Map<String, Object> fareBreakdown;
}
@@ -0,0 +1,13 @@
package com.ex.chatbot.kakao.web.dto;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class WebListCardItemDto {
private String title;
private String description;
private String imageUrl;
private String linkUrl;
}
@@ -0,0 +1,11 @@
package com.ex.chatbot.kakao.web.dto;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class WebQuickReplyDto {
private String label;
private String message;
}
@@ -0,0 +1,45 @@
# =============================================================================
# kakaoChatbotSkill — 카카오 웹훅 / nginx 진입 (웹 서버, WAS와 분리)
# WAS(172.16.180.130): chatbotApi :8086, exAiChatBot :28012, chatbotAdmin :8087
# LLM 게이트웨이: 172.16.163.96 (HTTPS, llm-ai.ex.co.kr 등)
# =============================================================================
server:
port: 8083
http2:
enabled: true
restTemplate:
factory:
readTimeout: 5000
connectTimeout: 3000
httpClient:
maxConnTotal: 100
maxConnPerRoute: 5
# Skill → WAS (legacy /llm-agent, domain-only 등)
chatbot-api:
base-url: ${CHATBOT_API_BASE_URL:http://172.16.180.130:8086/api/}
# data.ex.co.kr — WAS에서 접근 불가, Skill에서 직접 호출
data-portal:
base-url: https://data.ex.co.kr/openapi
api-key: "6123592782"
max-rows: 100
# WAS Agent tool → Skill internal 호출 시 인증 (WAS와 동일 키)
internal:
tool:
api-key: ${INTERNAL_TOOL_API_KEY:}
# Agent 모드: Skill → RAG /agent/chat (RAG는 WAS 서버 172.16.180.130)
agent:
mode: ${AGENT_MODE:legacy}
fallback-legacy: true
chat:
url: ${RAG_AGENT_CHAT_URL:http://172.16.180.130:28012/agent/chat}
timeout-ms: ${AGENT_CHAT_TIMEOUT_MS:100000}
# 카카오 폴백 → 웹 챗봇 리다이렉트 URL
chatbot-app:
url: ${CHATBOT_APP_URL:https://chatbot.ex.co.kr/chatbot/app/}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration scan="true" scanPeriod="30 seconds">
<!--property resource="resource.properties"/-->
<property name="LOG_DIR" value="./logs" />
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern> %d{HH:mm:ss.SSS,Asia/Seoul} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_DIR}/exKakaoChat.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_DIR}/exKakaoChat.%d{yyyy-MM-dd,Asia/Seoul}.log</fileNamePattern>
<maxHistory>90</maxHistory>
</rollingPolicy>
<encoder>
<pattern>%d{HH:mm:ss.SSS,Asia/Seoul} %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<appender name="HIKARI" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>${LOG_DIR}/Hikari.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>${LOG_DIR}/Hikari.%d{yyyy-MM-dd,Asia/Seoul}.log</fileNamePattern>
<maxHistory>90</maxHistory>
</rollingPolicy>
<encoder>
<pattern>%d{HH:mm:ss.SSS,Asia/Seoul} %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE"/>
</root>
<logger name="com.zaxxer.hikari" level="DEBUG">
<appender-ref ref="HIKARI"/>
</logger>
</configuration>
@@ -0,0 +1,10 @@
package com.ex.chatbot.kakao;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class KakaoApplicationTests {
}
@@ -0,0 +1,21 @@
package com.ex.chatbot.kakao.config;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class AgentPropertiesTest {
@Test
void isAgentMode_trueOnlyForAgentValue() {
AgentProperties props = new AgentProperties();
props.setMode("legacy");
assertThat(props.isAgentMode()).isFalse();
props.setMode("agent");
assertThat(props.isAgentMode()).isTrue();
props.setMode("AGENT");
assertThat(props.isAgentMode()).isTrue();
}
}
@@ -0,0 +1,50 @@
package com.ex.chatbot.kakao.response.build;
import com.ex.chatbot.kakao.response.element.View.BasicCardView;
import com.ex.chatbot.kakao.response.element.template.Template;
import com.ex.chatbot.kakao.response.element.type.buttons.WebLinkButton;
import org.junit.jupiter.api.Test;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class KakaoIntentAppFaqLinkTest {
@Test
void shouldAppendFaqLinkCardWithDeduplicatedButtons() throws Exception {
KakaoIntentApp app = new KakaoIntentApp();
List<Template> outputs = new ArrayList<>();
String url1 = "https://www.hipass.co.kr/board/FaqDetail.do?ser_no=186";
String url2 = "https://www.hipass.co.kr/board/FaqDetail.do?ser_no=192";
String url3 = "https://www.hipass.co.kr/board/FaqDetail.do?ser_no=200";
String url4 = "https://www.hipass.co.kr/board/FaqDetail.do?ser_no=201";
Map<String, Object> response = new HashMap<>();
response.put("faqUrls", Arrays.asList(url1, "", url1, url2, url3, url4));
Method method = KakaoIntentApp.class.getDeclaredMethod("appendFaqLinkCard", List.class, Map.class);
method.setAccessible(true);
method.invoke(app, outputs, response);
assertThat(outputs).hasSize(1);
BasicCardView cardView = (BasicCardView) outputs.get(0);
assertThat(cardView.getBasicCard().getTitle()).isEqualTo("하이패스 FAQ");
assertThat(cardView.getBasicCard().getButtons()).hasSize(3);
WebLinkButton firstButton = (WebLinkButton) cardView.getBasicCard().getButtons().get(0);
WebLinkButton secondButton = (WebLinkButton) cardView.getBasicCard().getButtons().get(1);
WebLinkButton thirdButton = (WebLinkButton) cardView.getBasicCard().getButtons().get(2);
assertThat(firstButton.getLabel()).isEqualTo("FAQ 원문 보기");
assertThat(firstButton.getWebLinkUrl()).isEqualTo(url1);
assertThat(secondButton.getWebLinkUrl()).isEqualTo(url2);
assertThat(thirdButton.getWebLinkUrl()).isEqualTo(url3);
}
}
@@ -0,0 +1,80 @@
package com.ex.chatbot.kakao.web;
import com.ex.chatbot.kakao.response.element.SkillResponse;
import com.ex.chatbot.kakao.response.element.template.ListCard;
import com.ex.chatbot.kakao.response.element.template.SimpleText;
import com.ex.chatbot.kakao.response.element.type.Item;
import com.ex.chatbot.kakao.response.element.type.QuickReply;
import com.ex.chatbot.kakao.web.dto.WebListCardDto;
import org.junit.jupiter.api.Test;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class SkillResponseWebExtractorTest {
private final SkillResponseWebExtractor extractor = new SkillResponseWebExtractor();
@Test
void extract_separatesSimpleTextAndListCards() {
List<Item> items = List.of(
Item.builder().title("1,965원").description("칠곡(부산)").build(),
Item.builder().title("1,954원").description("칠곡(부산)").build()
);
List<com.ex.chatbot.kakao.response.element.template.Template> outputs = new ArrayList<>();
outputs.add(SimpleText.getSimpleText("칠곡휴게소 주유 가격을 방향에 따라 안내드립니다."));
outputs.add(ListCard.getListCard("칠곡(부산) 주유정보입니다.", null, items, null));
SkillResponse response = new SkillResponse().getResult(outputs, List.of());
SkillResponseWebExtractor.WebExtractResult result = extractor.extract(response);
assertThat(result.getAnswer()).isEqualTo("칠곡휴게소 주유 가격을 방향에 따라 안내드립니다.");
assertThat(result.getListCards()).hasSize(1);
assertThat(result.getListCards().get(0).getTitle()).isEqualTo("칠곡(부산) 주유정보입니다.");
}
@Test
void extract_attachesFareBreakdownToFareCard() {
List<Item> items = List.of(
Item.builder().title("1종 : 3200원").description("승용차").build()
);
List<com.ex.chatbot.kakao.response.element.template.Template> outputs = new ArrayList<>();
outputs.add(SimpleText.getSimpleText("통행요금 안내입니다."));
outputs.add(ListCard.getListCard("판교 - 신갈 통행요금입니다.", null, items, null));
SkillResponse response = new SkillResponse().getResult(outputs, List.of());
Map<String, Object> breakdown = new HashMap<>();
breakdown.put("fromIc", "판교");
breakdown.put("toIc", "신갈");
breakdown.put("totalFare", 3200);
response.setData(Map.of("fareBreakdown", breakdown));
SkillResponseWebExtractor.WebExtractResult result = extractor.extract(response);
WebListCardDto card = result.getListCards().get(0);
assertThat(card.getFareBreakdown()).isNotNull();
assertThat(card.getFareBreakdown().get("fromIc")).isEqualTo("판교");
}
@Test
void extract_quickRepliesForWebResend() {
List<QuickReply> quickReplies = List.of(
QuickReply.getMessageReply("하이패스 충전 방법", "하이패스(선불) 충전은 어떻게 하나요?"),
QuickReply.getBlockReply("홈으로", "61ef9177b8ad900c148e68e2")
);
SkillResponse response = new SkillResponse().getResult(
List.of(SimpleText.getSimpleText("하이패스 충전 안내입니다.")),
quickReplies
);
SkillResponseWebExtractor.WebExtractResult result = extractor.extract(response);
assertThat(result.getQuickReplies()).hasSize(1);
assertThat(result.getQuickReplies().get(0).getMessage())
.isEqualTo("하이패스(선불) 충전은 어떻게 하나요?");
}
}
@@ -0,0 +1,71 @@
package com.ex.chatbot.kakao.web;
import com.ex.chatbot.kakao.web.dto.WebAskRequest;
import com.ex.chatbot.kakao.web.dto.WebAskResponse;
import com.ex.chatbot.kakao.web.dto.WebListCardDto;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(WebChatController.class)
class WebChatControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@MockBean
private WebChatService webChatService;
@Test
void ask_returnsWebAskResponse() throws Exception {
when(webChatService.ask(any(WebAskRequest.class))).thenReturn(
WebAskResponse.builder()
.status(true)
.answer("판교에서 신갈까지 통행요금 안내입니다.")
.botId("web-test")
.listCards(List.of(
WebListCardDto.builder()
.title("판교 - 신갈 통행요금입니다.")
.items(List.of())
.build()
))
.build()
);
WebAskRequest request = new WebAskRequest();
request.setQuery("판교에서 신갈까지 통행요금");
request.setBotId("web-test");
mockMvc.perform(post("/web/ask")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value(true))
.andExpect(jsonPath("$.answer").value("판교에서 신갈까지 통행요금 안내입니다."))
.andExpect(jsonPath("$.listCards[0].title").value("판교 - 신갈 통행요금입니다."));
}
@Test
void openApiSpec_isAvailable() throws Exception {
mockMvc.perform(get("/web/openapi.json"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.openapi").value("3.0.0"))
.andExpect(jsonPath("$.paths['/ask']").exists());
}
}