Compare commits
14 Commits
3d2aa6cf46
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c2129d42e | |||
| f9f710ec90 | |||
| 4cde775809 | |||
| 9abb770e37 | |||
| afc9cdcde6 | |||
| db4155c36d | |||
| 56d5752095 | |||
| 677a79978f | |||
| 1bfe55d5a8 | |||
| 9798cda41e | |||
| bb5a601433 | |||
| f0f7b62e3d | |||
| 0cc84354f5 | |||
| 9929322de0 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -67,3 +67,4 @@ oracle_data/
|
|||||||
# Claude Code
|
# Claude Code
|
||||||
# ========================
|
# ========================
|
||||||
.claude/
|
.claude/
|
||||||
|
cookies.txt
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
|
|
||||||
- 빌드: `cd backend && export JAVA_HOME=/opt/homebrew/Cellar/openjdk/25.0.2/libexec/openjdk.jdk/Contents/Home && set -a && source ../.env && set +a && mvn package -q -DskipTests`
|
- 빌드: `cd backend && export JAVA_HOME=/opt/homebrew/Cellar/openjdk/25.0.2/libexec/openjdk.jdk/Contents/Home && set -a && source ../.env && set +a && mvn package -q -DskipTests`
|
||||||
- 컴파일만: `cd backend && export JAVA_HOME=/opt/homebrew/Cellar/openjdk/25.0.2/libexec/openjdk.jdk/Contents/Home && set -a && source ../.env && set +a && mvn compile`
|
- 컴파일만: `cd backend && export JAVA_HOME=/opt/homebrew/Cellar/openjdk/25.0.2/libexec/openjdk.jdk/Contents/Home && set -a && source ../.env && set +a && mvn compile`
|
||||||
|
- 프론트엔드 빌드/배포: `cd sundol-frontend && bash build.sh` (환경변수 검증 포함)
|
||||||
|
- 프론트엔드 빌드 전 `.env`에 `NEXT_PUBLIC_GOOGLE_CLIENT_ID`, `NEXT_PUBLIC_API_URL` 필수. 없으면 Google OAuth 로그인 깨짐.
|
||||||
- 배포 시 반드시 `git push origin main` 포함
|
- 배포 시 반드시 `git push origin main` 포함
|
||||||
|
|
||||||
# DB 접속 (Oracle Autonomous DB - SQLcl)
|
# DB 접속 (Oracle Autonomous DB - SQLcl)
|
||||||
|
|||||||
223
docs/crawling-guide.md
Normal file
223
docs/crawling-guide.md
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
# 웹 크롤링 & YouTube 자막 추출 가이드
|
||||||
|
|
||||||
|
## 아키텍처 개요
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────┐
|
||||||
|
│ Spring Boot Application │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────────────────────────────────┐ │
|
||||||
|
│ │ PlaywrightBrowserService │ │
|
||||||
|
│ │ (싱글톤 Chromium 브라우저, 앱 기동 시 1회) │ │
|
||||||
|
│ │ - @PostConstruct: 브라우저 launch │ │
|
||||||
|
│ │ - @PreDestroy: 브라우저 close │ │
|
||||||
|
│ │ - 탭(Page) 단위로 작업 수행 │ │
|
||||||
|
│ │ - 브라우저 죽으면 자동 재시작 │ │
|
||||||
|
│ └──────────┬───────────────┬────────────────────┘ │
|
||||||
|
│ │ │ │
|
||||||
|
│ ┌──────────▼──────┐ ┌────▼──────────────────┐ │
|
||||||
|
│ │ WebCrawlerService│ │YouTubeTranscriptService│ │
|
||||||
|
│ │ (일반 웹페이지) │ │ (YouTube 자막) │ │
|
||||||
|
│ └─────────────────┘ └───────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 1. PlaywrightBrowserService (싱글톤 브라우저)
|
||||||
|
|
||||||
|
### 핵심 설계
|
||||||
|
|
||||||
|
- **싱글톤 패턴**: 앱 기동 시 Chromium을 한 번 띄우고 종료 시까지 유지
|
||||||
|
- **탭 기반 작업**: 각 크롤링 요청마다 새 탭(`Page`)을 열고, 완료 후 `about:blank`로 이동 (브라우저 창 유지)
|
||||||
|
- **자동 복구**: `ensureBrowserAlive()`로 브라우저 crash 시 자동 재시작
|
||||||
|
- **쿠키 로딩**: `cookies.txt`에서 YouTube/Google 쿠키를 읽어 봇 차단 우회
|
||||||
|
|
||||||
|
### 왜 싱글톤인가?
|
||||||
|
|
||||||
|
| 항목 | 매번 브라우저 생성 | 싱글톤 브라우저 |
|
||||||
|
|------|-------------------|----------------|
|
||||||
|
| 기동 시간 | 요청마다 2~3초 | 최초 1회만 |
|
||||||
|
| 메모리 | 매번 할당/해제 | 안정적 유지 |
|
||||||
|
| 쿠키/세션 | 매번 재로딩 | 유지됨 |
|
||||||
|
| VNC 디버깅 | 창이 뜨고 사라짐 | 항상 떠있어 관찰 가능 |
|
||||||
|
|
||||||
|
### 주요 메서드
|
||||||
|
|
||||||
|
```java
|
||||||
|
// 새 탭 열기 (호출자가 사용 후 about:blank 또는 close)
|
||||||
|
Page openPage(String url, int timeoutMs)
|
||||||
|
Page openPage(String url) // 기본 30초
|
||||||
|
|
||||||
|
// 브라우저 페이지 내에서 JS fetch 실행 (쿠키/세션 유지)
|
||||||
|
String fetchInPage(Page page, String url)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 탭 정리 방식
|
||||||
|
|
||||||
|
```java
|
||||||
|
// page.close() 대신 about:blank로 이동 → 브라우저 창이 유지됨
|
||||||
|
try {
|
||||||
|
page.navigate("about:blank");
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
page.close(); // about:blank 실패 시에만 탭 닫기
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 쿠키 파일 형식 (`cookies.txt`)
|
||||||
|
|
||||||
|
Netscape 쿠키 형식 (탭 구분):
|
||||||
|
|
||||||
|
```
|
||||||
|
.youtube.com / FALSE TRUE 1780000000 COOKIE_NAME COOKIE_VALUE
|
||||||
|
```
|
||||||
|
|
||||||
|
필드 순서: `domain`, `path`, `hostOnly(무시)`, `secure`, `expiry(무시)`, `name`, `value`
|
||||||
|
|
||||||
|
YouTube/Google 도메인 쿠키만 로드됨.
|
||||||
|
|
||||||
|
## 2. WebCrawlerService (일반 웹페이지 크롤링)
|
||||||
|
|
||||||
|
### 3단계 Fallback 전략
|
||||||
|
|
||||||
|
```
|
||||||
|
1차: Jsoup (경량 HTML 파서)
|
||||||
|
↓ 실패 또는 유효하지 않은 콘텐츠
|
||||||
|
2차: Jina Reader API (외부 서비스)
|
||||||
|
↓ 실패 또는 유효하지 않은 콘텐츠
|
||||||
|
3차: Playwright (싱글톤 브라우저 탭)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1차: Jsoup
|
||||||
|
|
||||||
|
- Java 기반 HTML 파서, 가장 빠르고 가벼움
|
||||||
|
- JavaScript 렌더링 불가 → SPA 사이트에서는 빈 콘텐츠 반환
|
||||||
|
- `nav, footer, header, script, style` 등 불필요 요소 제거 후 본문 추출
|
||||||
|
|
||||||
|
### 2차: Jina Reader
|
||||||
|
|
||||||
|
- `https://r.jina.ai/{URL}` 형태로 외부 서비스 호출
|
||||||
|
- JavaScript 렌더링 지원, Markdown/텍스트 형식 반환
|
||||||
|
- API 키 설정 시 인증 헤더 추가 가능
|
||||||
|
|
||||||
|
### 3차: Playwright
|
||||||
|
|
||||||
|
- 싱글톤 브라우저에서 새 탭으로 페이지 로드
|
||||||
|
- JavaScript 렌더링 완료 후 `NETWORKIDLE` 대기
|
||||||
|
- `page.evaluate()`로 DOM에서 직접 본문 텍스트 추출:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
() => {
|
||||||
|
// 불필요 요소 제거
|
||||||
|
['nav','footer','header','script','style','.ad','#cookie-banner','.sidebar','.comments']
|
||||||
|
.forEach(sel => document.querySelectorAll(sel).forEach(el => el.remove()));
|
||||||
|
// 본문 영역 우선, 없으면 body 전체
|
||||||
|
const article = document.querySelector('article, main, .post-content, .article-body, .entry-content');
|
||||||
|
return (article || document.body).innerText;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 유효성 검증
|
||||||
|
|
||||||
|
모든 단계에서 `isValidContent()` 검증:
|
||||||
|
- 최소 100자 이상
|
||||||
|
- 에러 페이지 패턴 감지 (403, captcha, "checking your browser" 등)
|
||||||
|
|
||||||
|
## 3. YouTubeTranscriptService (YouTube 자막 추출)
|
||||||
|
|
||||||
|
### 2단계 Fallback 전략
|
||||||
|
|
||||||
|
```
|
||||||
|
1차: youtube-transcript-api 라이브러리
|
||||||
|
↓ 실패 (봇 차단 등)
|
||||||
|
2차: Playwright (싱글톤 브라우저)
|
||||||
|
├─ 방법 A: 자막 패널 DOM 추출
|
||||||
|
└─ 방법 B: Caption URL + fmt=json3
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1차: youtube-transcript-api
|
||||||
|
|
||||||
|
- `io.github.thoroldvix` 라이브러리 사용
|
||||||
|
- 수동 자막(manual) 우선, 없으면 자동 생성(generated) 시도
|
||||||
|
- 언어 우선순위: `ko` → `en`
|
||||||
|
- **한계**: YouTube가 서버 IP를 봇으로 판정하면 실패
|
||||||
|
|
||||||
|
### 2차: Playwright Fallback
|
||||||
|
|
||||||
|
#### 방법 A: 자막 패널 DOM 추출 (우선)
|
||||||
|
|
||||||
|
YouTube 페이지에서 '스크립트 표시' 버튼을 클릭하여 자막 패널을 열고 DOM에서 텍스트를 직접 추출한다.
|
||||||
|
|
||||||
|
**흐름**:
|
||||||
|
1. YouTube 동영상 페이지 로드 (NETWORKIDLE 대기)
|
||||||
|
2. 2초 대기 후 '스크립트 표시' / 'Show transcript' 버튼 탐색 및 클릭
|
||||||
|
3. 버튼 못 찾으면 설명란 '더보기' 펼치기 → 다시 탐색
|
||||||
|
4. 3초 대기 (자막 패널 로드)
|
||||||
|
5. `ytd-transcript-segment-renderer .segment-text` 등 CSS 셀렉터로 자막 텍스트 추출
|
||||||
|
|
||||||
|
**장점**: 브라우저의 쿠키/세션이 완전히 유지되어 봇 차단 우회 가능
|
||||||
|
|
||||||
|
#### 방법 B: Caption URL + fmt=json3 (보조)
|
||||||
|
|
||||||
|
방법 A가 실패하면 HTML에서 `captionTracks`를 파싱하여 caption URL을 추출하고, `&fmt=json3` 파라미터를 추가하여 JSON 형식으로 자막을 요청한다.
|
||||||
|
|
||||||
|
**왜 fmt=json3인가?**
|
||||||
|
- 기본 XML 형식(`fmt` 없음)은 서명된 URL이 서버 IP에 바인딩되어 빈 응답을 반환하는 경우가 있음
|
||||||
|
- `fmt=json3`은 `{"events":[{"segs":[{"utf8":"text"}]}]}` 형식으로 반환
|
||||||
|
|
||||||
|
**Caption URL 추출 과정**:
|
||||||
|
1. `page.content()`로 HTML 획득
|
||||||
|
2. `"captionTracks":\s*\[(.*?)]` 정규식으로 JSON 추출
|
||||||
|
3. 언어 우선순위에 따라 URL 선택 (ko → en → 첫 번째)
|
||||||
|
4. `\u0026` → `&` 디코딩
|
||||||
|
5. `&fmt=json3` 추가
|
||||||
|
6. `page.evaluate(fetch())` — 브라우저 내 JS fetch로 요청 (쿠키 유지)
|
||||||
|
|
||||||
|
### 언어 선택 로직
|
||||||
|
|
||||||
|
```
|
||||||
|
captionTracks JSON에서:
|
||||||
|
1. languageCode가 "ko"로 시작하는 트랙 → 최우선
|
||||||
|
2. languageCode가 "en"으로 시작하는 트랙 → 차선
|
||||||
|
3. 첫 번째 트랙 → 최후
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. DISPLAY 환경변수와 Head 모드
|
||||||
|
|
||||||
|
Playwright가 head 모드(`setHeadless(false)`)로 실행되려면 X Window 디스플레이가 필요하다.
|
||||||
|
|
||||||
|
| 환경 | DISPLAY 값 | 설명 |
|
||||||
|
|------|-----------|------|
|
||||||
|
| VNC 디버깅 | `:1` | VNC 접속 시 브라우저 보임 |
|
||||||
|
| 운영 (백그라운드) | `:99` | Xvfb 가상 프레임버퍼 |
|
||||||
|
|
||||||
|
설정 위치: `start-backend.sh`의 `export DISPLAY=:1` (또는 `:99`)
|
||||||
|
|
||||||
|
## 5. 트러블슈팅
|
||||||
|
|
||||||
|
### YouTube 자막이 안 가져와질 때
|
||||||
|
|
||||||
|
1. **로그 확인**: `pm2 logs sundol-backend --lines 50`
|
||||||
|
2. **"봇 판정"**: youtube-transcript-api 실패 → Playwright fallback 정상 동작하는지 확인
|
||||||
|
3. **쿠키 만료**: `cookies.txt`의 쿠키가 오래되면 YouTube가 차단. 브라우저에서 새 쿠키 export 필요
|
||||||
|
4. **자막 패널 버튼 변경**: YouTube UI 업데이트로 버튼 텍스트/셀렉터가 바뀌면 `extractTranscriptFromPanel()` 수정 필요
|
||||||
|
|
||||||
|
### Playwright 브라우저가 죽었을 때
|
||||||
|
|
||||||
|
- `ensureBrowserAlive()`가 자동 재시작
|
||||||
|
- 로그에서 `Playwright 브라우저가 죽어있습니다. 재시작합니다.` 확인
|
||||||
|
- 수동 재시작: `pm2 restart sundol-backend`
|
||||||
|
|
||||||
|
### 크롤링이 빈 결과를 반환할 때
|
||||||
|
|
||||||
|
1. Jsoup: JavaScript 렌더링이 필요한 SPA 사이트인지 확인
|
||||||
|
2. Jina Reader: API 키 유효한지, rate limit 걸렸는지 확인
|
||||||
|
3. Playwright: 해당 사이트가 Cloudflare 등 봇 차단을 하는지 확인
|
||||||
|
|
||||||
|
## 6. 관련 파일
|
||||||
|
|
||||||
|
| 파일 | 역할 |
|
||||||
|
|------|------|
|
||||||
|
| `PlaywrightBrowserService.java` | 싱글톤 브라우저 관리 |
|
||||||
|
| `WebCrawlerService.java` | 일반 웹 크롤링 (3단계 fallback) |
|
||||||
|
| `YouTubeTranscriptService.java` | YouTube 자막 추출 (2단계 fallback) |
|
||||||
|
| `cookies.txt` | YouTube/Google 쿠키 (봇 차단 우회) |
|
||||||
|
| `start-backend.sh` | DISPLAY 환경변수 설정 |
|
||||||
182
docs/operation-manual.md
Normal file
182
docs/operation-manual.md
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
# Sundol 운영 매뉴얼
|
||||||
|
|
||||||
|
## 프로세스 구성
|
||||||
|
|
||||||
|
pm2로 3개 프로세스를 관리한다.
|
||||||
|
|
||||||
|
| id | name | 설명 |
|
||||||
|
|----|------|------|
|
||||||
|
| 0 | pdf-ocr | PDF OCR 서비스 |
|
||||||
|
| 1 | sundol-backend | Spring Boot 백엔드 |
|
||||||
|
| 2 | sundol-frontend | Next.js 프론트엔드 |
|
||||||
|
|
||||||
|
## pm2 기본 명령어
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 프로세스 목록 확인
|
||||||
|
pm2 list
|
||||||
|
|
||||||
|
# 로그 확인
|
||||||
|
pm2 logs sundol-backend
|
||||||
|
pm2 logs sundol-frontend
|
||||||
|
pm2 logs pdf-ocr
|
||||||
|
|
||||||
|
# 특정 프로세스 재시작
|
||||||
|
pm2 restart sundol-backend
|
||||||
|
pm2 restart sundol-frontend
|
||||||
|
pm2 restart pdf-ocr
|
||||||
|
|
||||||
|
# 전체 재시작
|
||||||
|
pm2 restart all
|
||||||
|
|
||||||
|
# 프로세스 중지
|
||||||
|
pm2 stop sundol-backend
|
||||||
|
|
||||||
|
# 프로세스 삭제
|
||||||
|
pm2 delete sundol-backend
|
||||||
|
```
|
||||||
|
|
||||||
|
## 백엔드 시작 스크립트
|
||||||
|
|
||||||
|
파일: `start-backend.sh`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
set -a
|
||||||
|
source /home/opc/sundol/.env
|
||||||
|
set +a
|
||||||
|
|
||||||
|
JAVA_HOME=${JAVA_HOME:-/usr/lib/jvm/java-21}
|
||||||
|
export JAVA_HOME
|
||||||
|
|
||||||
|
# Playwright 브라우저 경로
|
||||||
|
export PLAYWRIGHT_BROWSERS_PATH=/home/opc/.cache/ms-playwright
|
||||||
|
export PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
|
||||||
|
|
||||||
|
# Playwright head 모드용 디스플레이
|
||||||
|
# :99 = Xvfb 가상 디스플레이 (브라우저 안 보임)
|
||||||
|
# :1 = VNC 디스플레이 (VNC에서 브라우저 보임)
|
||||||
|
export DISPLAY=:99
|
||||||
|
|
||||||
|
# Xvfb 가상 디스플레이 시작 (DISPLAY=:99일 때만 필요)
|
||||||
|
if ! pgrep -x Xvfb > /dev/null; then
|
||||||
|
Xvfb :99 -screen 0 1280x720x24 -nolisten tcp &
|
||||||
|
sleep 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
BACKEND_DIR=/home/opc/sundol/sundol-backend
|
||||||
|
exec $JAVA_HOME/bin/java -cp "$BACKEND_DIR/target/classes:$BACKEND_DIR/target/dependency/*" com.sundol.SundolApplication
|
||||||
|
```
|
||||||
|
|
||||||
|
### DISPLAY 설정 가이드
|
||||||
|
|
||||||
|
| DISPLAY 값 | 용도 |
|
||||||
|
|-------------|------|
|
||||||
|
| `:99` | Xvfb 가상 디스플레이. 브라우저가 백그라운드에서 동작 (기본값) |
|
||||||
|
| `:1` | VNC 디스플레이. VNC 접속 시 Playwright 브라우저가 화면에 보임 (디버깅용) |
|
||||||
|
|
||||||
|
VNC에서 Playwright 브라우저를 직접 보려면:
|
||||||
|
1. `start-backend.sh`에서 `export DISPLAY=:1`로 변경
|
||||||
|
2. `pm2 restart sundol-backend`
|
||||||
|
|
||||||
|
## 백엔드 빌드
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/opc/sundol/sundol-backend
|
||||||
|
export JAVA_HOME=/usr/lib/jvm/java-21
|
||||||
|
set -a && source /home/opc/sundol/.env && set +a
|
||||||
|
mvn package -q -DskipTests
|
||||||
|
```
|
||||||
|
|
||||||
|
> 컴파일만: `mvn compile` (위 환경변수 동일)
|
||||||
|
|
||||||
|
## 프론트엔드 빌드/배포
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/opc/sundol/sundol-frontend
|
||||||
|
bash build.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
> `.env`에 `NEXT_PUBLIC_GOOGLE_CLIENT_ID`, `NEXT_PUBLIC_API_URL` 필수.
|
||||||
|
|
||||||
|
## 배포 절차
|
||||||
|
|
||||||
|
1. 코드 변경 및 빌드
|
||||||
|
2. `pm2 restart sundol-backend` (또는 sundol-frontend)
|
||||||
|
3. `pm2 logs sundol-backend` 로 정상 기동 확인
|
||||||
|
4. `git push origin main`
|
||||||
|
|
||||||
|
## DB 접속 (Oracle Autonomous DB)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set -a && source /home/opc/sundol/.env && set +a
|
||||||
|
/home/opc/sqlcl/bin/sql ${ORACLE_USERNAME}/${ORACLE_PASSWORD}@${ORACLE_TNS_NAME}?TNS_ADMIN=${ORACLE_WALLET_PATH}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Playwright 관련
|
||||||
|
|
||||||
|
- 브라우저 경로: `/home/opc/.cache/ms-playwright`
|
||||||
|
- WebCrawlerService: Playwright head 모드 (`setHeadless(false)`)
|
||||||
|
- YouTubeTranscriptService: Playwright head 모드 (`setHeadless(false)`)
|
||||||
|
- head 모드 사용 시 DISPLAY 환경변수 필수
|
||||||
|
|
||||||
|
## Playwright 의존 라이브러리
|
||||||
|
|
||||||
|
Playwright Chromium 실행에 필요한 시스템 라이브러리:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo dnf install -y libicu woff2 harfbuzz-icu libjpeg-turbo libwebp enchant2 hyphen libffi
|
||||||
|
```
|
||||||
|
|
||||||
|
> `libx264`는 WebKit 전용이므로 Chromium만 사용할 경우 불필요.
|
||||||
|
> 로그에 `validateDependenciesLinux` 에러가 나오면 위 패키지 설치 확인.
|
||||||
|
|
||||||
|
### Xvfb vs VNC 디스플레이
|
||||||
|
|
||||||
|
| 모드 | DISPLAY | 설명 |
|
||||||
|
|------|---------|------|
|
||||||
|
| Xvfb (운영) | `:99` | 가상 프레임버퍼. 화면 없이 백그라운드 동작 |
|
||||||
|
| VNC (디버깅) | `:1` | VNC 접속 시 브라우저가 화면에 보임 |
|
||||||
|
|
||||||
|
Xvfb 모드로 되돌리려면 `start-backend.sh`에서:
|
||||||
|
```bash
|
||||||
|
export DISPLAY=:99
|
||||||
|
if ! pgrep -x Xvfb > /dev/null; then
|
||||||
|
Xvfb :99 -screen 0 1280x720x24 -nolisten tcp &
|
||||||
|
sleep 1
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
## 트러블슈팅
|
||||||
|
|
||||||
|
### 백엔드가 안 뜰 때
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pm2 logs sundol-backend --lines 50
|
||||||
|
```
|
||||||
|
|
||||||
|
### Playwright 브라우저가 안 열릴 때
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# DISPLAY 확인
|
||||||
|
echo $DISPLAY
|
||||||
|
|
||||||
|
# Xvfb 실행 여부 확인
|
||||||
|
pgrep -x Xvfb
|
||||||
|
|
||||||
|
# VNC 실행 여부 확인
|
||||||
|
vncserver -list
|
||||||
|
|
||||||
|
# Playwright 브라우저 설치 확인
|
||||||
|
ls /home/opc/.cache/ms-playwright/
|
||||||
|
```
|
||||||
|
|
||||||
|
### pm2 프로세스가 계속 재시작될 때
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 재시작 횟수 확인 (↺ 컬럼)
|
||||||
|
pm2 list
|
||||||
|
|
||||||
|
# 에러 로그 확인
|
||||||
|
pm2 logs sundol-backend --err --lines 100
|
||||||
|
```
|
||||||
174
docs/setup-xwindow.md
Normal file
174
docs/setup-xwindow.md
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
# X Window + XFCE + VNC 설치 매뉴얼
|
||||||
|
|
||||||
|
OCI(Oracle Cloud Infrastructure) Oracle Linux 9 환경 기준.
|
||||||
|
|
||||||
|
## 1. 사전 확인
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Xorg 설치 여부 확인
|
||||||
|
which Xorg
|
||||||
|
rpm -q xorg-x11-server-Xorg
|
||||||
|
|
||||||
|
# DISPLAY 환경변수 확인 (비어있으면 X 서버 미실행 상태)
|
||||||
|
echo $DISPLAY
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. EPEL 저장소 설치
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo dnf install -y epel-release
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. XFCE 데스크톱 환경 설치
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo dnf groupinstall -y "Xfce"
|
||||||
|
```
|
||||||
|
|
||||||
|
> GNOME 대비 경량이라 서버 환경에 적합.
|
||||||
|
|
||||||
|
## 4. 기본 타겟을 GUI 모드로 변경
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl set-default graphical.target
|
||||||
|
```
|
||||||
|
|
||||||
|
> 되돌리려면: `sudo systemctl set-default multi-user.target`
|
||||||
|
|
||||||
|
## 5. TigerVNC 서버 설치
|
||||||
|
|
||||||
|
SSH 원격 접속 환경에서는 VNC를 통해 GUI에 접근한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo dnf install -y tigervnc-server
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. VNC 비밀번호 설정
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vncpasswd
|
||||||
|
```
|
||||||
|
|
||||||
|
> VNC 비밀번호는 최대 8자까지만 유효.
|
||||||
|
|
||||||
|
## 7. VNC 시작 스크립트 설정
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.vnc
|
||||||
|
|
||||||
|
cat > ~/.vnc/xstartup << 'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
exec startxfce4
|
||||||
|
EOF
|
||||||
|
|
||||||
|
chmod +x ~/.vnc/xstartup
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. VNC 서버 시작
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vncserver :1 -geometry 1920x1080 -depth 24
|
||||||
|
```
|
||||||
|
|
||||||
|
- `:1` = 포트 5901
|
||||||
|
- `:2` = 포트 5902 (추가 세션 필요 시)
|
||||||
|
|
||||||
|
### VNC 서버 중지
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vncserver -kill :1
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. 방화벽 포트 개방
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo firewall-cmd --permanent --add-port=5901/tcp
|
||||||
|
sudo firewall-cmd --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
### OCI 보안 목록 설정
|
||||||
|
|
||||||
|
OCI 콘솔에서 해당 서브넷의 Security List에 인바운드 규칙 추가:
|
||||||
|
- **Source**: 접속할 IP (또는 0.0.0.0/0)
|
||||||
|
- **Protocol**: TCP
|
||||||
|
- **Destination Port**: 5901
|
||||||
|
|
||||||
|
## 10. 클라이언트에서 VNC 접속
|
||||||
|
|
||||||
|
### Mac
|
||||||
|
|
||||||
|
기본 내장 Screen Sharing 사용:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Finder에서 Cmd+K → 아래 주소 입력
|
||||||
|
vnc://공인IP:5901
|
||||||
|
|
||||||
|
# 또는 터미널에서
|
||||||
|
open vnc://공인IP:5901
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
- RealVNC Viewer, TigerVNC Viewer 등 VNC 클라이언트 설치
|
||||||
|
- 주소: `공인IP:5901`
|
||||||
|
|
||||||
|
## 11. Chrome 브라우저 설치
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Google Chrome 저장소 추가
|
||||||
|
cat << 'EOF' | sudo tee /etc/yum.repos.d/google-chrome.repo
|
||||||
|
[google-chrome]
|
||||||
|
name=google-chrome
|
||||||
|
baseurl=https://dl.google.com/linux/chrome/rpm/stable/x86_64
|
||||||
|
enabled=1
|
||||||
|
gpgcheck=1
|
||||||
|
gpgkey=https://dl.google.com/linux/linux_signing_key.pub
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# 설치
|
||||||
|
sudo dnf install -y google-chrome-stable
|
||||||
|
```
|
||||||
|
|
||||||
|
## 12. 한글 폰트 설치
|
||||||
|
|
||||||
|
브라우저 등에서 한글이 깨지는 경우 CJK 폰트를 설치한다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo dnf install -y google-noto-sans-cjk-ttc-fonts google-noto-serif-cjk-ttc-fonts
|
||||||
|
|
||||||
|
# 폰트 캐시 갱신
|
||||||
|
fc-cache -fv
|
||||||
|
```
|
||||||
|
|
||||||
|
> 설치 후 Chrome 등 브라우저를 재시작해야 반영됨.
|
||||||
|
|
||||||
|
## 트러블슈팅
|
||||||
|
|
||||||
|
### VNC 접속이 안 될 때
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# VNC 서버 실행 확인
|
||||||
|
vncserver -list
|
||||||
|
|
||||||
|
# 로그 확인
|
||||||
|
cat ~/.vnc/*.log
|
||||||
|
|
||||||
|
# 방화벽 확인
|
||||||
|
sudo firewall-cmd --list-ports
|
||||||
|
```
|
||||||
|
|
||||||
|
### 화면이 회색/빈 화면일 때
|
||||||
|
|
||||||
|
`~/.vnc/xstartup` 파일 내용이 올바른지 확인:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat ~/.vnc/xstartup
|
||||||
|
# exec startxfce4 가 있어야 함
|
||||||
|
```
|
||||||
|
|
||||||
|
VNC 서버 재시작:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vncserver -kill :1
|
||||||
|
vncserver :1 -geometry 1920x1080 -depth 24
|
||||||
|
```
|
||||||
309
docs/troubleshooting.md
Normal file
309
docs/troubleshooting.md
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
# 트러블슈팅 가이드
|
||||||
|
|
||||||
|
## 1. 프론트엔드 502 Bad Gateway
|
||||||
|
|
||||||
|
### 증상
|
||||||
|
- 브라우저에서 `502 Bad Gateway nginx/1.20.1` 표시
|
||||||
|
|
||||||
|
### 원인
|
||||||
|
Next.js standalone 빌드 시 `server.js`가 `sundol-frontend/` 서브디렉토리에 생성되는 경우가 있음.
|
||||||
|
PM2가 `.next/standalone/server.js`를 찾지만 실제 파일은 `.next/standalone/sundol-frontend/server.js`에 위치.
|
||||||
|
|
||||||
|
### 확인 방법
|
||||||
|
```bash
|
||||||
|
pm2 list # sundol-frontend 상태 확인 (errored 여부)
|
||||||
|
pm2 logs sundol-frontend --err --lines 20 # 에러 로그 확인
|
||||||
|
# "Cannot find module .../standalone/server.js" 에러가 나면 이 문제
|
||||||
|
```
|
||||||
|
|
||||||
|
### 해결
|
||||||
|
```bash
|
||||||
|
# server.js 심볼릭 링크 생성
|
||||||
|
ln -sf /home/opc/sundol/sundol-frontend/.next/standalone/sundol-frontend/server.js \
|
||||||
|
/home/opc/sundol/sundol-frontend/.next/standalone/server.js
|
||||||
|
|
||||||
|
pm2 restart sundol-frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
> `build.sh`에 이미 자동 처리 로직 포함되어 있음. 문제가 반복되면 build.sh 확인.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 프론트엔드 static 파일 404
|
||||||
|
|
||||||
|
### 증상
|
||||||
|
- 페이지는 로드되지만 CSS/JS가 404
|
||||||
|
- 브라우저 콘솔에 `_next/static/chunks/...js net::ERR_ABORTED 404` 다수 표시
|
||||||
|
|
||||||
|
### 원인
|
||||||
|
standalone 빌드에서 `.next/static` 심볼릭 링크가 올바른 위치에 걸리지 않음.
|
||||||
|
`server.js`가 `standalone/sundol-frontend/` 안에서 실행되므로 static도 그 안에 있어야 함.
|
||||||
|
|
||||||
|
### 해결
|
||||||
|
```bash
|
||||||
|
# 중첩 디렉토리에도 static 링크 생성
|
||||||
|
ln -sf /home/opc/sundol/sundol-frontend/.next/static \
|
||||||
|
/home/opc/sundol/sundol-frontend/.next/standalone/sundol-frontend/.next/static
|
||||||
|
|
||||||
|
pm2 restart sundol-frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
> `build.sh`에 자동 처리 로직 포함되어 있음.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 브라우저 로그인 모달 (HTTP Basic Auth 팝업)
|
||||||
|
|
||||||
|
### 증상
|
||||||
|
- 페이지 리프레시 시 브라우저 기본 로그인 팝업(username/password)이 뜸
|
||||||
|
- 특히 JWT 토큰이 만료되었거나 없을 때 발생
|
||||||
|
|
||||||
|
### 원인
|
||||||
|
Spring Security가 401 응답에 `WWW-Authenticate: Basic` 헤더를 포함.
|
||||||
|
브라우저가 이 헤더를 감지하면 자동으로 Basic Auth 로그인 다이얼로그를 표시.
|
||||||
|
|
||||||
|
### 해결
|
||||||
|
`SecurityConfig.java`에 커스텀 `authenticationEntryPoint` 설정:
|
||||||
|
|
||||||
|
```java
|
||||||
|
.exceptionHandling(exceptions -> exceptions
|
||||||
|
.authenticationEntryPoint((exchange, ex) -> {
|
||||||
|
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
|
||||||
|
return exchange.getResponse().setComplete();
|
||||||
|
})
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
이렇게 하면 `WWW-Authenticate` 헤더 없이 401만 반환되어 브라우저 팝업이 뜨지 않음.
|
||||||
|
프론트엔드의 axios 인터셉터가 401을 감지하여 자동으로 토큰 갱신 처리.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. YouTube 자막 가져오기 실패 (Caption XML 0 chars)
|
||||||
|
|
||||||
|
### 증상
|
||||||
|
- YouTube 트랜스크립트 가져오기 시 "자막 텍스트를 파싱할 수 없습니다" 에러
|
||||||
|
- 로그에 `Caption XML fetched: 0 chars` 표시
|
||||||
|
|
||||||
|
### 원인
|
||||||
|
YouTube timedtext API의 caption URL이 서명 기반으로, 브라우저 세션 외부에서 요청하면 빈 응답 반환.
|
||||||
|
`HttpURLConnection`이나 `context.request().get()`으로는 쿠키/세션이 유지되지 않음.
|
||||||
|
|
||||||
|
### 해결
|
||||||
|
두 가지 방법으로 fallback 처리:
|
||||||
|
|
||||||
|
1. **방법 A (우선)**: YouTube 페이지에서 '스크립트 표시' 패널을 열어 DOM에서 직접 텍스트 추출
|
||||||
|
2. **방법 B**: caption URL에 `&fmt=json3` 추가 후 `page.evaluate(fetch())`로 브라우저 내에서 요청
|
||||||
|
|
||||||
|
자세한 내용은 [crawling-guide.md](crawling-guide.md) 참조.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Playwright 브라우저 창이 사라짐
|
||||||
|
|
||||||
|
### 증상
|
||||||
|
- VNC에서 Playwright Chromium 창이 보이다가 사라짐
|
||||||
|
|
||||||
|
### 원인
|
||||||
|
`page.close()` 호출 시 마지막 탭이 닫히면 브라우저 창이 빈 상태가 됨.
|
||||||
|
|
||||||
|
### 해결
|
||||||
|
`page.close()` 대신 `page.navigate("about:blank")`으로 변경하여 탭을 유지:
|
||||||
|
|
||||||
|
```java
|
||||||
|
try {
|
||||||
|
page.navigate("about:blank");
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
page.close();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Playwright 의존 라이브러리 누락 경고
|
||||||
|
|
||||||
|
### 증상
|
||||||
|
- 백엔드 로그에 `Host system is missing dependencies to run browsers` 경고
|
||||||
|
- `libicudata.so.66`, `libwoff2dec.so.1.0.2` 등 누락 표시
|
||||||
|
|
||||||
|
### 원인
|
||||||
|
Playwright가 WebKit 브라우저용 의존성까지 검사함. Chromium만 사용하면 대부분 문제없음.
|
||||||
|
|
||||||
|
### 확인
|
||||||
|
```bash
|
||||||
|
# Chromium 실행 가능 여부 직접 테스트
|
||||||
|
/home/opc/.cache/ms-playwright/chromium-1161/chrome-linux/chrome --version
|
||||||
|
```
|
||||||
|
|
||||||
|
### 해결 (경고 제거하려면)
|
||||||
|
```bash
|
||||||
|
sudo dnf install -y libicu woff2 harfbuzz-icu libjpeg-turbo libwebp enchant2 hyphen libffi
|
||||||
|
```
|
||||||
|
|
||||||
|
> `libx264`는 WebKit 전용이므로 Chromium만 사용 시 불필요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Git Push 인증 실패
|
||||||
|
|
||||||
|
### 증상
|
||||||
|
- `git push origin main` 시 `could not read Username` 에러
|
||||||
|
|
||||||
|
### 원인
|
||||||
|
`.netrc`이나 `.git-credentials`에 인증 정보가 없음.
|
||||||
|
|
||||||
|
### 해결
|
||||||
|
`.env`의 `GIT_USER`, `GIT_PASSWORD` 사용 (비밀번호에 특수문자 포함 시 URL 인코딩 필요):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
set -a && source /home/opc/sundol/.env && set +a
|
||||||
|
ENCODED_PW=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$GIT_PASSWORD', safe=''))")
|
||||||
|
git push "https://${GIT_USER}:${ENCODED_PW}@gittea.cloud-handson.com/joungmin/sundol.git" main
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. LLM 구조화 내용이 잘림
|
||||||
|
|
||||||
|
### 증상
|
||||||
|
- 지식 정리(구조화) 결과가 중간에 끊김
|
||||||
|
|
||||||
|
### 원인
|
||||||
|
OCI GenAI의 `maxTokens`가 4096으로 제한되어 있어 긴 콘텐츠 정리 시 응답이 잘림.
|
||||||
|
|
||||||
|
### 해결
|
||||||
|
한 번에 전체를 정리하지 않고, 나눠서 요청하는 방식으로 변경:
|
||||||
|
|
||||||
|
1. **1차 호출**: Abstract + 목차만 생성
|
||||||
|
2. **2차~ 호출**: 목차 항목별로 상세 정리 요청 (각각 maxTokens 4096 내에서 충분)
|
||||||
|
3. **최종 조합**: 모든 결과를 합침
|
||||||
|
|
||||||
|
각 섹션 완료 시 DB에 중간 저장하여 프론트엔드에서 실시간 확인 가능.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. VNC 접속 안 됨
|
||||||
|
|
||||||
|
### 증상
|
||||||
|
- VNC 클라이언트에서 접속 불가
|
||||||
|
|
||||||
|
### 확인
|
||||||
|
```bash
|
||||||
|
# VNC 서버 실행 확인
|
||||||
|
vncserver -list
|
||||||
|
|
||||||
|
# 방화벽 확인
|
||||||
|
sudo firewall-cmd --list-ports
|
||||||
|
|
||||||
|
# OCI 보안 목록에서 TCP 5901 인바운드 허용 여부 확인
|
||||||
|
```
|
||||||
|
|
||||||
|
### 해결
|
||||||
|
```bash
|
||||||
|
# VNC 서버 재시작
|
||||||
|
vncserver -kill :1
|
||||||
|
vncserver :1 -geometry 1920x1080 -depth 24
|
||||||
|
|
||||||
|
# 방화벽 포트 열기
|
||||||
|
sudo firewall-cmd --permanent --add-port=5901/tcp
|
||||||
|
sudo firewall-cmd --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
자세한 내용은 [setup-xwindow.md](setup-xwindow.md) 참조.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. VNC 접속 시 스크린 락(화면 잠금) 걸림
|
||||||
|
|
||||||
|
### 증상
|
||||||
|
- VNC로 접속하면 검은 화면에 로그인 다이얼로그가 뜸
|
||||||
|
- 잠시 자리를 비운 뒤 접속하면 사용자 비밀번호를 요구
|
||||||
|
|
||||||
|
### 원인
|
||||||
|
XFCE 스크린세이버가 기본 활성화되어 있어, 일정 시간 비활성 후 화면을 잠금.
|
||||||
|
|
||||||
|
### 해결
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export DISPLAY=:1
|
||||||
|
|
||||||
|
# 스크린세이버 비활성화
|
||||||
|
xfconf-query -c xfce4-screensaver -p /saver/enabled --create -t bool -s false
|
||||||
|
|
||||||
|
# 화면 잠금 비활성화
|
||||||
|
xfconf-query -c xfce4-screensaver -p /lock/enabled --create -t bool -s false
|
||||||
|
xfconf-query -c xfce4-screensaver -p /lock/saver-activation/enabled --create -t bool -s false
|
||||||
|
|
||||||
|
# 모니터 절전(DPMS) 비활성화
|
||||||
|
xfconf-query -c xfce4-power-manager -p /xfce4-power-manager/dpms-enabled --create -t bool -s false
|
||||||
|
xfconf-query -c xfce4-power-manager -p /xfce4-power-manager/lock-screen-suspend-hibernate --create -t bool -s false
|
||||||
|
|
||||||
|
# 잠금 명령 제거
|
||||||
|
xfconf-query -c xfce4-session -p /general/LockCommand --create -t string -s ""
|
||||||
|
|
||||||
|
# 실행 중인 screensaver 프로세스 종료
|
||||||
|
ps aux | grep -E 'xfce4-screensaver|screensaver-dialog' | grep -v grep | awk '{print $2}' | xargs kill 2>/dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. YouTube 자막 추출 시 광고 때문에 실패
|
||||||
|
|
||||||
|
### 증상
|
||||||
|
- Playwright로 YouTube 페이지 로드 후 자막 패널이 열리지 않음
|
||||||
|
- VNC에서 보면 광고가 재생 중
|
||||||
|
|
||||||
|
### 원인
|
||||||
|
YouTube 동영상 앞에 프리롤 광고가 삽입되면 본 영상 UI(자막 패널 등)가 활성화되지 않음.
|
||||||
|
|
||||||
|
### 해결
|
||||||
|
코드에 `waitForAdsToFinish()` 로직이 포함되어 있음:
|
||||||
|
- 최대 60초간 2초 간격으로 광고 상태 확인
|
||||||
|
- 스킵 버튼(`.ytp-skip-ad-button` 등)이 나타나면 자동 클릭
|
||||||
|
- 광고가 끝나면 자막 추출 진행
|
||||||
|
|
||||||
|
수동으로 확인하려면 VNC에서 Playwright 브라우저 창을 직접 관찰.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. 백엔드 재시작 시 로그인이 풀림
|
||||||
|
|
||||||
|
### 증상
|
||||||
|
- 백엔드(`pm2 restart sundol-backend`) 후 프론트엔드에서 로그인 화면으로 튕김
|
||||||
|
- 로그에 `User logged out: null` 표시
|
||||||
|
|
||||||
|
### 원인
|
||||||
|
1. 백엔드 재시작 중 프론트엔드가 API 호출 → 연결 실패(네트워크 에러)
|
||||||
|
2. axios 인터셉터가 401로 인식 → refresh 시도 → 서버 아직 안 떠서 실패
|
||||||
|
3. `onRefreshFailed` 콜백 → 자동 로그아웃 실행
|
||||||
|
|
||||||
|
### 해결
|
||||||
|
프론트엔드 axios 인터셉터에서 refresh 실패 시 네트워크 에러이면 3초 후 최대 2회 재시도:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const attemptRefresh = async (retryCount: number): Promise<string> => {
|
||||||
|
try {
|
||||||
|
const res = await api.post<LoginResponse>("/api/auth/refresh");
|
||||||
|
return res.data.accessToken;
|
||||||
|
} catch (err) {
|
||||||
|
const isNetworkError = !((err as AxiosError).response);
|
||||||
|
if (isNetworkError && retryCount < 2) {
|
||||||
|
await new Promise((r) => setTimeout(r, 3000));
|
||||||
|
return attemptRefresh(retryCount + 1);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
백엔드 logout에도 userId null 체크 추가:
|
||||||
|
```java
|
||||||
|
if (userId == null) {
|
||||||
|
log.warn("Logout called with null userId, ignoring");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 예방
|
||||||
|
- 백엔드 재시작 시 기동 완료까지 약 10~25초 소요
|
||||||
|
- 프론트엔드는 네트워크 에러 시 최대 9초(3초 × 3회) 대기 후 재시도
|
||||||
207
docs/youtube-transcript-guide.md
Normal file
207
docs/youtube-transcript-guide.md
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
# YouTube 트랜스크립트 추출 가이드
|
||||||
|
|
||||||
|
## 아키텍처
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────┐
|
||||||
|
│ 사용자 Chrome 브라우저 │
|
||||||
|
│ (VNC에서 직접 YouTube 로그인된 상태) │
|
||||||
|
│ --remote-debugging-port=9222 │
|
||||||
|
│ --remote-allow-origins=* │
|
||||||
|
└──────────────┬───────────────────────────────────┘
|
||||||
|
│ CDP (Chrome DevTools Protocol)
|
||||||
|
┌──────────────▼───────────────────────────────────┐
|
||||||
|
│ PlaywrightBrowserService │
|
||||||
|
│ playwright.chromium().connectOverCDP() │
|
||||||
|
│ → 사용자 Chrome의 세션/쿠키를 그대로 사용 │
|
||||||
|
└──────────────┬───────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌──────────────▼───────────────────────────────────┐
|
||||||
|
│ YouTubeTranscriptService │
|
||||||
|
│ 1차: youtube-transcript-api 라이브러리 │
|
||||||
|
│ 2차: Playwright → 사용자 Chrome 탭으로 추출 │
|
||||||
|
└──────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 핵심: 왜 사용자 Chrome인가?
|
||||||
|
|
||||||
|
### Playwright 자체 브라우저의 한계
|
||||||
|
- Playwright가 실행하는 Chromium에는 `--enable-automation` 플래그가 붙음
|
||||||
|
- YouTube가 이를 감지하여 **봇으로 판정** → 자막 API 차단
|
||||||
|
- 쿠키를 로드해도, 직접 로그인을 시도해도 차단됨
|
||||||
|
- Google 로그인 자체가 "안전하지 않은 브라우저"로 거부됨
|
||||||
|
|
||||||
|
### 사용자 Chrome + CDP 방식
|
||||||
|
- VNC에서 일반 Chrome으로 YouTube에 로그인
|
||||||
|
- 백엔드가 CDP로 해당 Chrome에 연결하여 새 탭을 열어 작업
|
||||||
|
- **로그인 세션이 그대로 유지**되므로 봇 판정 우회
|
||||||
|
- Chrome 자체는 종료하지 않고 연결만 관리
|
||||||
|
|
||||||
|
## Chrome 실행 방법
|
||||||
|
|
||||||
|
### 필수 옵션
|
||||||
|
```bash
|
||||||
|
DISPLAY=:1 google-chrome \
|
||||||
|
--remote-debugging-port=9222 \
|
||||||
|
--remote-allow-origins=* \
|
||||||
|
--user-data-dir=/tmp/chrome-debug-profile \
|
||||||
|
--no-default-browser-check \
|
||||||
|
"https://www.youtube.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
| 옵션 | 설명 |
|
||||||
|
|------|------|
|
||||||
|
| `--remote-debugging-port=9222` | CDP 접속 포트 |
|
||||||
|
| `--remote-allow-origins=*` | CDP WebSocket 연결 허용 |
|
||||||
|
| `--user-data-dir` | 프로필 디렉토리 (기본과 다른 경로 필요) |
|
||||||
|
|
||||||
|
### Chrome 프로필 복사 (기존 로그인 유지)
|
||||||
|
```bash
|
||||||
|
cp -r /home/opc/.config/google-chrome /tmp/chrome-debug-profile
|
||||||
|
```
|
||||||
|
|
||||||
|
### CDP 연결 확인
|
||||||
|
```bash
|
||||||
|
curl -s http://localhost:9222/json/version | python3 -m json.tool
|
||||||
|
```
|
||||||
|
|
||||||
|
## YouTube 로그인
|
||||||
|
|
||||||
|
1. VNC로 접속 (`vnc://공인IP:5901`)
|
||||||
|
2. Chrome에서 YouTube가 열려있는지 확인
|
||||||
|
3. YouTube에 Google 계정으로 로그인
|
||||||
|
4. 로그인 상태 유지 — Chrome을 닫지 않음
|
||||||
|
|
||||||
|
> Chrome을 닫으면 CDP 연결이 끊기고 백엔드가 재연결을 시도함.
|
||||||
|
> Chrome을 다시 시작하면 다시 로그인 필요.
|
||||||
|
|
||||||
|
## 트랜스크립트 추출 흐름
|
||||||
|
|
||||||
|
### 1차: youtube-transcript-api 라이브러리
|
||||||
|
```
|
||||||
|
videoId → TranscriptApi.listTranscripts()
|
||||||
|
→ 수동 자막(ko, en) 우선
|
||||||
|
→ 자동 생성 자막 fallback
|
||||||
|
```
|
||||||
|
- 서버 IP가 봇으로 판정되면 실패 (대부분 실패)
|
||||||
|
|
||||||
|
### 2차: Playwright + 사용자 Chrome
|
||||||
|
```
|
||||||
|
1. 사용자 Chrome에 새 탭 열기 (DOMCONTENTLOADED, 60초 타임아웃)
|
||||||
|
2. DOM 렌더링 대기 (3초)
|
||||||
|
3. 동영상 재생 시작 (playVideo)
|
||||||
|
- 쿠키 동의 팝업 닫기
|
||||||
|
- 마우스 호버 → 재생 버튼 클릭
|
||||||
|
4. 광고 대기 (waitForAdsToFinish, 최대 60초)
|
||||||
|
- 스킵 버튼 자동 클릭
|
||||||
|
- 광고 끝날 때까지 반복 확인
|
||||||
|
5. 실제 영상 재생 확인 (waitForVideoPlaying, 최대 15초)
|
||||||
|
- video.currentTime > 0.5 확인
|
||||||
|
- 일시정지면 재시도
|
||||||
|
6. ytInitialPlayerResponse에서 자막 URL 추출 + fetch (fetchTranscriptFromPageJs)
|
||||||
|
7. 실패 시 자막 패널 열기 (extractTranscriptFromPanel)
|
||||||
|
- 'Show transcript' 버튼 클릭
|
||||||
|
- 자막 세그먼트 DOM에서 텍스트 추출
|
||||||
|
- 최대 30초 반복 확인
|
||||||
|
8. 완료 후 about:blank로 이동 (탭 유지)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 광고 처리
|
||||||
|
|
||||||
|
### 감지 방법
|
||||||
|
- `#movie_player.ad-showing` 클래스 확인 (가장 신뢰할 수 있음)
|
||||||
|
- `.ytp-ad-player-overlay` 오버레이 확인
|
||||||
|
- 광고 텍스트 배지 확인 (`.ytp-ad-text`)
|
||||||
|
|
||||||
|
### 스킵 버튼 셀렉터
|
||||||
|
```css
|
||||||
|
.ytp-skip-ad-button,
|
||||||
|
.ytp-ad-skip-button,
|
||||||
|
.ytp-ad-skip-button-modern,
|
||||||
|
.ytp-ad-skip-button-container button
|
||||||
|
```
|
||||||
|
|
||||||
|
### 주의사항
|
||||||
|
- 광고 스킵 후 두 번째 광고가 나올 수 있음 → `no_ad` 상태 확인까지 반복
|
||||||
|
- 광고 중에는 `NETWORKIDLE`에 도달하지 못함 → `DOMCONTENTLOADED` 사용
|
||||||
|
|
||||||
|
## 쿠키 관리
|
||||||
|
|
||||||
|
### CDP 방식에서는 쿠키 파일 불필요
|
||||||
|
- 사용자 Chrome에 직접 연결하므로 `cookies.txt` 불필요
|
||||||
|
- Chrome 세션의 쿠키가 자동으로 사용됨
|
||||||
|
|
||||||
|
### Chrome 쿠키 수동 export (참고용)
|
||||||
|
Chrome을 `--remote-debugging-port=9222`로 실행 후:
|
||||||
|
```python
|
||||||
|
import json, websocket, urllib.request
|
||||||
|
|
||||||
|
tabs = json.loads(urllib.request.urlopen("http://localhost:9222/json").read())
|
||||||
|
ws_url = tabs[0]["webSocketDebuggerUrl"]
|
||||||
|
ws = websocket.create_connection(ws_url)
|
||||||
|
ws.send(json.dumps({"id": 1, "method": "Network.getAllCookies"}))
|
||||||
|
result = json.loads(ws.recv())
|
||||||
|
cookies = result["result"]["cookies"]
|
||||||
|
ws.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
> Chrome의 쿠키 DB(`~/.config/google-chrome/Default/Cookies`)는 암호화되어 있어 직접 읽기 어려움.
|
||||||
|
> CDP를 통해 복호화된 쿠키를 가져오는 것이 가장 확실한 방법.
|
||||||
|
|
||||||
|
## PM2 + Chrome 자동 시작
|
||||||
|
|
||||||
|
Chrome이 서버 재부팅 시에도 자동 시작되도록:
|
||||||
|
```bash
|
||||||
|
# pm2로 Chrome 관리하지 않음 (GUI 앱이라 적합하지 않음)
|
||||||
|
# 대신 VNC 시작 시 자동 실행하도록 ~/.vnc/xstartup에 추가:
|
||||||
|
```
|
||||||
|
|
||||||
|
`~/.vnc/xstartup`:
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
exec startxfce4 &
|
||||||
|
|
||||||
|
# Chrome을 CDP 모드로 자동 시작
|
||||||
|
sleep 5
|
||||||
|
google-chrome --remote-debugging-port=9222 \
|
||||||
|
--remote-allow-origins=* \
|
||||||
|
--user-data-dir=/tmp/chrome-debug-profile \
|
||||||
|
--no-default-browser-check \
|
||||||
|
"https://www.youtube.com" &
|
||||||
|
```
|
||||||
|
|
||||||
|
## 트러블슈팅
|
||||||
|
|
||||||
|
### Chrome CDP 연결 실패
|
||||||
|
```
|
||||||
|
Chrome CDP 연결 실패: Chrome이 --remote-debugging-port=9222로 실행 중인지 확인하세요.
|
||||||
|
```
|
||||||
|
→ Chrome 재시작:
|
||||||
|
```bash
|
||||||
|
DISPLAY=:1 google-chrome --remote-debugging-port=9222 --remote-allow-origins=* \
|
||||||
|
--user-data-dir=/tmp/chrome-debug-profile --no-default-browser-check "https://www.youtube.com" &
|
||||||
|
```
|
||||||
|
|
||||||
|
### 자막 패널이 ghost-cards만 표시
|
||||||
|
- YouTube가 자막 데이터 로드를 차단한 상태
|
||||||
|
- Chrome에서 YouTube 로그인이 풀렸는지 VNC에서 확인
|
||||||
|
- 로그인 상태라면 Chrome 재시작 후 다시 로그인
|
||||||
|
|
||||||
|
### "안전하지 않은 브라우저" 로그인 거부
|
||||||
|
- Playwright 자체 브라우저에서 발생 (정상)
|
||||||
|
- **사용자 Chrome**에서만 로그인 가능
|
||||||
|
- CDP 방식으로 전환하면 해결
|
||||||
|
|
||||||
|
### 광고 중에 페이지가 닫힘
|
||||||
|
- YouTube 페이지 로드 시 `NETWORKIDLE` 사용하면 광고 때문에 타임아웃
|
||||||
|
- **`DOMCONTENTLOADED`** + 60초 타임아웃으로 변경하여 해결
|
||||||
|
|
||||||
|
## 관련 파일
|
||||||
|
|
||||||
|
| 파일 | 역할 |
|
||||||
|
|------|------|
|
||||||
|
| `PlaywrightBrowserService.java` | 사용자 Chrome에 CDP 연결, 탭 관리 |
|
||||||
|
| `YouTubeTranscriptService.java` | 자막 추출 로직 (API → Playwright fallback) |
|
||||||
|
| `youtube-transcript-extract.js` | 자막 패널 DOM 추출 JS |
|
||||||
|
| `youtube-transcript-from-page.js` | ytInitialPlayerResponse 기반 자막 추출 JS |
|
||||||
|
| `~/.vnc/xstartup` | VNC 시작 시 Chrome 자동 실행 |
|
||||||
@@ -7,6 +7,7 @@ module.exports = {
|
|||||||
cwd: "/home/opc/sundol",
|
cwd: "/home/opc/sundol",
|
||||||
env: {
|
env: {
|
||||||
JAVA_HOME: "/usr/lib/jvm/java-21",
|
JAVA_HOME: "/usr/lib/jvm/java-21",
|
||||||
|
PLAYWRIGHT_NODEJS_PATH: "/home/opc/.playwright-driver/driver/linux/node",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -17,6 +18,8 @@ module.exports = {
|
|||||||
env: {
|
env: {
|
||||||
PORT: 3000,
|
PORT: 3000,
|
||||||
HOSTNAME: "0.0.0.0",
|
HOSTNAME: "0.0.0.0",
|
||||||
|
NEXT_PUBLIC_API_URL: "https://sundol.cloud-handson.com",
|
||||||
|
NEXT_PUBLIC_GOOGLE_CLIENT_ID: "906390686133-vpqsisodkg6uqui469hg8dhupbejoa0d.apps.googleusercontent.com",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -6,4 +6,14 @@ set +a
|
|||||||
JAVA_HOME=${JAVA_HOME:-/usr/lib/jvm/java-21}
|
JAVA_HOME=${JAVA_HOME:-/usr/lib/jvm/java-21}
|
||||||
export JAVA_HOME
|
export JAVA_HOME
|
||||||
|
|
||||||
exec $JAVA_HOME/bin/java -jar /home/opc/sundol/sundol-backend/target/sundol-backend-0.0.1-SNAPSHOT.jar
|
# Playwright: use pre-installed browsers, skip auto-download
|
||||||
|
export PLAYWRIGHT_BROWSERS_PATH=/home/opc/.cache/ms-playwright
|
||||||
|
export PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
|
||||||
|
|
||||||
|
# Xvfb virtual display for Playwright head mode (YouTube transcript)
|
||||||
|
# :99 = Xvfb (백그라운드), :1 = VNC (디버깅용)
|
||||||
|
export DISPLAY=:1
|
||||||
|
|
||||||
|
# Playwright driver-bundle requires exploded classpath (fat JAR extraction fails)
|
||||||
|
BACKEND_DIR=/home/opc/sundol/sundol-backend
|
||||||
|
exec $JAVA_HOME/bin/java -cp "$BACKEND_DIR/target/classes:$BACKEND_DIR/target/dependency/*" com.sundol.SundolApplication
|
||||||
|
|||||||
@@ -56,10 +56,12 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.oracle.database.security</groupId>
|
<groupId>com.oracle.database.security</groupId>
|
||||||
<artifactId>osdt_cert</artifactId>
|
<artifactId>osdt_cert</artifactId>
|
||||||
|
<version>21.18.0.0</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.oracle.database.security</groupId>
|
<groupId>com.oracle.database.security</groupId>
|
||||||
<artifactId>osdt_core</artifactId>
|
<artifactId>osdt_core</artifactId>
|
||||||
|
<version>21.18.0.0</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- JWT -->
|
<!-- JWT -->
|
||||||
@@ -81,6 +83,13 @@
|
|||||||
<scope>runtime</scope>
|
<scope>runtime</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Google Auth Library -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.google.auth</groupId>
|
||||||
|
<artifactId>google-auth-library-oauth2-http</artifactId>
|
||||||
|
<version>1.30.1</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- Lombok -->
|
<!-- Lombok -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.projectlombok</groupId>
|
<groupId>org.projectlombok</groupId>
|
||||||
@@ -95,6 +104,25 @@
|
|||||||
<version>1.18.3</version>
|
<version>1.18.3</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- YouTube Transcript API -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.github.thoroldvix</groupId>
|
||||||
|
<artifactId>youtube-transcript-api</artifactId>
|
||||||
|
<version>0.4.0</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Playwright (headless browser, driver-bundle includes node runtime) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.microsoft.playwright</groupId>
|
||||||
|
<artifactId>playwright</artifactId>
|
||||||
|
<version>1.51.0</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.microsoft.playwright</groupId>
|
||||||
|
<artifactId>driver-bundle</artifactId>
|
||||||
|
<version>1.51.0</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- Jackson -->
|
<!-- Jackson -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.fasterxml.jackson.core</groupId>
|
<groupId>com.fasterxml.jackson.core</groupId>
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import org.springframework.security.config.annotation.web.reactive.EnableWebFlux
|
|||||||
import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
|
import org.springframework.security.config.web.server.SecurityWebFiltersOrder;
|
||||||
import org.springframework.security.config.web.server.ServerHttpSecurity;
|
import org.springframework.security.config.web.server.ServerHttpSecurity;
|
||||||
import org.springframework.security.web.server.SecurityWebFilterChain;
|
import org.springframework.security.web.server.SecurityWebFilterChain;
|
||||||
|
import org.springframework.security.web.server.ServerAuthenticationEntryPoint;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.web.cors.CorsConfiguration;
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
import org.springframework.web.cors.reactive.CorsConfigurationSource;
|
import org.springframework.web.cors.reactive.CorsConfigurationSource;
|
||||||
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
|
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
|
||||||
@@ -41,6 +43,12 @@ public class SecurityConfig {
|
|||||||
.addFilterAt(jwtAuthenticationFilter, SecurityWebFiltersOrder.AUTHENTICATION)
|
.addFilterAt(jwtAuthenticationFilter, SecurityWebFiltersOrder.AUTHENTICATION)
|
||||||
.httpBasic(ServerHttpSecurity.HttpBasicSpec::disable)
|
.httpBasic(ServerHttpSecurity.HttpBasicSpec::disable)
|
||||||
.formLogin(ServerHttpSecurity.FormLoginSpec::disable)
|
.formLogin(ServerHttpSecurity.FormLoginSpec::disable)
|
||||||
|
.exceptionHandling(exceptions -> exceptions
|
||||||
|
.authenticationEntryPoint((exchange, ex) -> {
|
||||||
|
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
|
||||||
|
return exchange.getResponse().setComplete();
|
||||||
|
})
|
||||||
|
)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
package com.sundol.controller;
|
package com.sundol.controller;
|
||||||
|
|
||||||
import com.sundol.dto.LoginRequest;
|
|
||||||
import com.sundol.dto.LoginResponse;
|
import com.sundol.dto.LoginResponse;
|
||||||
import com.sundol.dto.RefreshRequest;
|
|
||||||
import com.sundol.service.AuthService;
|
import com.sundol.service.AuthService;
|
||||||
|
import org.springframework.http.HttpCookie;
|
||||||
|
import org.springframework.http.ResponseCookie;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.http.server.reactive.ServerHttpRequest;
|
||||||
|
import org.springframework.http.server.reactive.ServerHttpResponse;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/auth")
|
@RequestMapping("/api/auth")
|
||||||
public class AuthController {
|
public class AuthController {
|
||||||
@@ -19,19 +25,63 @@ public class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/google")
|
@PostMapping("/google")
|
||||||
public Mono<ResponseEntity<LoginResponse>> googleLogin(@RequestBody LoginRequest request) {
|
public Mono<ResponseEntity<LoginResponse>> googleLogin(
|
||||||
return authService.googleLogin(request.idToken())
|
@RequestBody Map<String, String> body,
|
||||||
.map(ResponseEntity::ok);
|
ServerHttpResponse response) {
|
||||||
|
String code = body.get("code");
|
||||||
|
return authService.googleLogin(code)
|
||||||
|
.map(loginResponse -> {
|
||||||
|
// Set refresh token as HttpOnly cookie
|
||||||
|
ResponseCookie cookie = ResponseCookie.from("refreshToken", loginResponse.refreshToken())
|
||||||
|
.httpOnly(true)
|
||||||
|
.secure(true)
|
||||||
|
.path("/api/auth")
|
||||||
|
.maxAge(Duration.ofDays(30))
|
||||||
|
.sameSite("Strict")
|
||||||
|
.build();
|
||||||
|
response.addCookie(cookie);
|
||||||
|
return ResponseEntity.ok(loginResponse);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/refresh")
|
@PostMapping("/refresh")
|
||||||
public Mono<ResponseEntity<LoginResponse>> refresh(@RequestBody RefreshRequest request) {
|
public Mono<ResponseEntity<LoginResponse>> refresh(ServerHttpRequest request, ServerHttpResponse response) {
|
||||||
return authService.refresh(request.refreshToken())
|
HttpCookie cookie = request.getCookies().getFirst("refreshToken");
|
||||||
.map(ResponseEntity::ok);
|
String refreshToken = cookie != null ? cookie.getValue() : null;
|
||||||
|
|
||||||
|
if (refreshToken == null) {
|
||||||
|
return Mono.just(ResponseEntity.status(401).build());
|
||||||
|
}
|
||||||
|
|
||||||
|
return authService.refresh(refreshToken)
|
||||||
|
.map(loginResponse -> {
|
||||||
|
// Rotate cookie
|
||||||
|
ResponseCookie newCookie = ResponseCookie.from("refreshToken", loginResponse.refreshToken())
|
||||||
|
.httpOnly(true)
|
||||||
|
.secure(true)
|
||||||
|
.path("/api/auth")
|
||||||
|
.maxAge(Duration.ofDays(30))
|
||||||
|
.sameSite("Strict")
|
||||||
|
.build();
|
||||||
|
response.addCookie(newCookie);
|
||||||
|
return ResponseEntity.ok(loginResponse);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/logout")
|
@PostMapping("/logout")
|
||||||
public Mono<ResponseEntity<Void>> logout(@RequestAttribute("userId") String userId) {
|
public Mono<ResponseEntity<Void>> logout(
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
ServerHttpResponse response) {
|
||||||
|
// Clear cookie
|
||||||
|
ResponseCookie cookie = ResponseCookie.from("refreshToken", "")
|
||||||
|
.httpOnly(true)
|
||||||
|
.secure(true)
|
||||||
|
.path("/api/auth")
|
||||||
|
.maxAge(0)
|
||||||
|
.sameSite("Strict")
|
||||||
|
.build();
|
||||||
|
response.addCookie(cookie);
|
||||||
|
|
||||||
return authService.logout(userId)
|
return authService.logout(userId)
|
||||||
.then(Mono.just(ResponseEntity.ok().<Void>build()));
|
.then(Mono.just(ResponseEntity.ok().<Void>build()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
package com.sundol.controller;
|
package com.sundol.controller;
|
||||||
|
|
||||||
import com.sundol.dto.IngestRequest;
|
import com.sundol.dto.IngestRequest;
|
||||||
|
import com.sundol.repository.CategoryRepository;
|
||||||
import com.sundol.service.KnowledgeService;
|
import com.sundol.service.KnowledgeService;
|
||||||
|
import com.sundol.service.YouTubeTranscriptService;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
@@ -15,10 +19,18 @@ import java.util.Map;
|
|||||||
@RequestMapping("/api/knowledge")
|
@RequestMapping("/api/knowledge")
|
||||||
public class KnowledgeController {
|
public class KnowledgeController {
|
||||||
|
|
||||||
private final KnowledgeService knowledgeService;
|
private static final Logger log = LoggerFactory.getLogger(KnowledgeController.class);
|
||||||
|
|
||||||
public KnowledgeController(KnowledgeService knowledgeService) {
|
private final KnowledgeService knowledgeService;
|
||||||
|
private final YouTubeTranscriptService youTubeTranscriptService;
|
||||||
|
private final CategoryRepository categoryRepository;
|
||||||
|
|
||||||
|
public KnowledgeController(KnowledgeService knowledgeService,
|
||||||
|
YouTubeTranscriptService youTubeTranscriptService,
|
||||||
|
CategoryRepository categoryRepository) {
|
||||||
this.knowledgeService = knowledgeService;
|
this.knowledgeService = knowledgeService;
|
||||||
|
this.youTubeTranscriptService = youTubeTranscriptService;
|
||||||
|
this.categoryRepository = categoryRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@@ -40,6 +52,19 @@ public class KnowledgeController {
|
|||||||
.map(result -> ResponseEntity.status(HttpStatus.ACCEPTED).body(result));
|
.map(result -> ResponseEntity.status(HttpStatus.ACCEPTED).body(result));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/youtube-transcript")
|
||||||
|
public Mono<ResponseEntity<Map<String, Object>>> fetchYouTubeTranscript(
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@RequestParam String url) {
|
||||||
|
return Mono.fromCallable(() -> youTubeTranscriptService.fetchTranscript(url))
|
||||||
|
.map(transcript -> ResponseEntity.ok(Map.<String, Object>of("transcript", transcript)))
|
||||||
|
.onErrorResume(e -> {
|
||||||
|
log.error("YouTube transcript error: {}", e.getMessage(), e);
|
||||||
|
return Mono.just(ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||||
|
.body(Map.of("error", e.getMessage() != null ? e.getMessage() : "트랜스크립트를 가져올 수 없습니다")));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}")
|
@GetMapping("/{id}")
|
||||||
public Mono<ResponseEntity<Map<String, Object>>> getById(
|
public Mono<ResponseEntity<Map<String, Object>>> getById(
|
||||||
@AuthenticationPrincipal String userId,
|
@AuthenticationPrincipal String userId,
|
||||||
@@ -65,6 +90,38 @@ public class KnowledgeController {
|
|||||||
.then(Mono.just(ResponseEntity.ok().<Void>build()));
|
.then(Mono.just(ResponseEntity.ok().<Void>build()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{id}/structure")
|
||||||
|
public Mono<ResponseEntity<Map<String, Object>>> structure(
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@PathVariable String id,
|
||||||
|
@RequestBody(required = false) Map<String, String> body) {
|
||||||
|
String modelId = body != null ? body.get("modelId") : null;
|
||||||
|
String englishLevel = body != null ? body.get("englishLevel") : null;
|
||||||
|
return knowledgeService.structureContent(userId, id, modelId, englishLevel)
|
||||||
|
.map(ResponseEntity::ok);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/categories")
|
||||||
|
public Mono<ResponseEntity<Map<String, Object>>> categories(@AuthenticationPrincipal String userId) {
|
||||||
|
return Mono.fromCallable(() -> {
|
||||||
|
var categories = categoryRepository.findAllByUserWithCount(userId);
|
||||||
|
var uncategorized = categoryRepository.findUncategorizedItems(userId);
|
||||||
|
return ResponseEntity.ok(Map.<String, Object>of(
|
||||||
|
"categories", categories,
|
||||||
|
"uncategorized", uncategorized
|
||||||
|
));
|
||||||
|
}).subscribeOn(reactor.core.scheduler.Schedulers.boundedElastic());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/categories/{categoryId}/items")
|
||||||
|
public Mono<ResponseEntity<List<Map<String, Object>>>> itemsByCategory(
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@PathVariable String categoryId) {
|
||||||
|
return Mono.fromCallable(() -> categoryRepository.findItemsByCategoryId(userId, categoryId))
|
||||||
|
.subscribeOn(reactor.core.scheduler.Schedulers.boundedElastic())
|
||||||
|
.map(ResponseEntity::ok);
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/{id}/chunks")
|
@GetMapping("/{id}/chunks")
|
||||||
public Mono<ResponseEntity<List<Map<String, Object>>>> getChunks(
|
public Mono<ResponseEntity<List<Map<String, Object>>>> getChunks(
|
||||||
@AuthenticationPrincipal String userId,
|
@AuthenticationPrincipal String userId,
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.sundol.controller;
|
||||||
|
|
||||||
|
import com.sundol.service.OciGenAiService;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/models")
|
||||||
|
public class ModelController {
|
||||||
|
|
||||||
|
private final OciGenAiService genAiService;
|
||||||
|
|
||||||
|
public ModelController(OciGenAiService genAiService) {
|
||||||
|
this.genAiService = genAiService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<Map<String, Object>> listModels() {
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"models", OciGenAiService.AVAILABLE_MODELS,
|
||||||
|
"defaultModel", genAiService.getDefaultModel(),
|
||||||
|
"configured", genAiService.isConfigured()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.sundol.controller;
|
||||||
|
|
||||||
|
import com.sundol.repository.UserRepository;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.core.scheduler.Schedulers;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/users")
|
||||||
|
public class UserController {
|
||||||
|
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
public UserController(UserRepository userRepository) {
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/me")
|
||||||
|
public Mono<ResponseEntity<Map<String, Object>>> me(@AuthenticationPrincipal String userId) {
|
||||||
|
return Mono.fromCallable(() -> {
|
||||||
|
Map<String, Object> user = userRepository.findById(userId);
|
||||||
|
if (user == null) {
|
||||||
|
return ResponseEntity.notFound().<Map<String, Object>>build();
|
||||||
|
}
|
||||||
|
// refresh_token은 응답에서 제외
|
||||||
|
user.remove("REFRESH_TOKEN");
|
||||||
|
return ResponseEntity.ok(user);
|
||||||
|
}).subscribeOn(Schedulers.boundedElastic());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/me")
|
||||||
|
public Mono<ResponseEntity<Map<String, Object>>> updateMe(
|
||||||
|
@AuthenticationPrincipal String userId,
|
||||||
|
@RequestBody Map<String, Object> updates) {
|
||||||
|
return Mono.fromCallable(() -> {
|
||||||
|
if (updates.containsKey("englishLevel")) {
|
||||||
|
String level = (String) updates.get("englishLevel");
|
||||||
|
if (level != null && level.matches("^(A1|A2|B1|B2|C1|C2)$")) {
|
||||||
|
userRepository.updateEnglishLevel(userId, level);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Map<String, Object> user = userRepository.findById(userId);
|
||||||
|
if (user != null) user.remove("REFRESH_TOKEN");
|
||||||
|
return ResponseEntity.ok(user);
|
||||||
|
}).subscribeOn(Schedulers.boundedElastic());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
package com.sundol.dto;
|
package com.sundol.dto;
|
||||||
|
|
||||||
public record IngestRequest(String type, String url, String title, String rawText) {}
|
public record IngestRequest(String type, String url, String title, String rawText, String modelId, String englishLevel) {}
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package com.sundol.repository;
|
||||||
|
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public class CategoryRepository {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
public CategoryRepository(JdbcTemplate jdbcTemplate) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* full_path로 카테고리 조회. 없으면 null.
|
||||||
|
*/
|
||||||
|
public Map<String, Object> findByUserAndPath(String userId, String fullPath) {
|
||||||
|
var results = jdbcTemplate.queryForList(
|
||||||
|
"SELECT RAWTOHEX(id) AS id, name, RAWTOHEX(parent_id) AS parent_id, depth, full_path " +
|
||||||
|
"FROM categories WHERE user_id = HEXTORAW(?) AND full_path = ?",
|
||||||
|
userId, fullPath
|
||||||
|
);
|
||||||
|
return results.isEmpty() ? null : results.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리 생성. 이미 존재하면 기존 ID 반환.
|
||||||
|
* full_path 예: "건강/운동/웨이트트레이닝"
|
||||||
|
*/
|
||||||
|
public String findOrCreate(String userId, String fullPath) {
|
||||||
|
Map<String, Object> existing = findByUserAndPath(userId, fullPath);
|
||||||
|
if (existing != null) {
|
||||||
|
return (String) existing.get("ID");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 경로 파싱
|
||||||
|
String[] parts = fullPath.split("/");
|
||||||
|
int depth = parts.length;
|
||||||
|
String name = parts[parts.length - 1];
|
||||||
|
|
||||||
|
// 부모 카테고리 확보 (재귀)
|
||||||
|
String parentId = null;
|
||||||
|
if (depth > 1) {
|
||||||
|
String parentPath = fullPath.substring(0, fullPath.lastIndexOf('/'));
|
||||||
|
parentId = findOrCreate(userId, parentPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 삽입
|
||||||
|
if (parentId != null) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO categories (id, user_id, name, parent_id, depth, full_path, created_at) " +
|
||||||
|
"VALUES (SYS_GUID(), HEXTORAW(?), ?, HEXTORAW(?), ?, ?, SYSTIMESTAMP)",
|
||||||
|
userId, name, parentId, depth, fullPath
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO categories (id, user_id, name, parent_id, depth, full_path, created_at) " +
|
||||||
|
"VALUES (SYS_GUID(), HEXTORAW(?), ?, NULL, ?, ?, SYSTIMESTAMP)",
|
||||||
|
userId, name, depth, fullPath
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> created = findByUserAndPath(userId, fullPath);
|
||||||
|
return (String) created.get("ID");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* knowledge_item에 카테고리 매핑
|
||||||
|
*/
|
||||||
|
public void linkCategory(String knowledgeItemId, String categoryId) {
|
||||||
|
// 중복 방지
|
||||||
|
Integer count = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM knowledge_item_categories WHERE knowledge_item_id = HEXTORAW(?) AND category_id = HEXTORAW(?)",
|
||||||
|
Integer.class, knowledgeItemId, categoryId
|
||||||
|
);
|
||||||
|
if (count != null && count > 0) return;
|
||||||
|
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO knowledge_item_categories (knowledge_item_id, category_id) VALUES (HEXTORAW(?), HEXTORAW(?))",
|
||||||
|
knowledgeItemId, categoryId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* knowledge_item의 카테고리 목록 조회
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> findByKnowledgeItemId(String knowledgeItemId) {
|
||||||
|
return jdbcTemplate.queryForList(
|
||||||
|
"SELECT RAWTOHEX(c.id) AS id, c.name, c.depth, c.full_path " +
|
||||||
|
"FROM categories c " +
|
||||||
|
"JOIN knowledge_item_categories kic ON kic.category_id = c.id " +
|
||||||
|
"WHERE kic.knowledge_item_id = HEXTORAW(?) " +
|
||||||
|
"ORDER BY c.full_path",
|
||||||
|
knowledgeItemId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자의 전체 카테고리 트리 조회
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> findAllByUser(String userId) {
|
||||||
|
return jdbcTemplate.queryForList(
|
||||||
|
"SELECT RAWTOHEX(id) AS id, name, RAWTOHEX(parent_id) AS parent_id, depth, full_path " +
|
||||||
|
"FROM categories WHERE user_id = HEXTORAW(?) ORDER BY full_path",
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리 트리 + 해당 카테고리 및 하위 카테고리에 속한 고유 항목 수 조회
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> findAllByUserWithCount(String userId) {
|
||||||
|
return jdbcTemplate.queryForList(
|
||||||
|
"SELECT RAWTOHEX(c.id) AS id, c.name, RAWTOHEX(c.parent_id) AS parent_id, c.depth, c.full_path, " +
|
||||||
|
" (SELECT COUNT(DISTINCT kic.knowledge_item_id) " +
|
||||||
|
" FROM knowledge_item_categories kic " +
|
||||||
|
" JOIN categories c2 ON c2.id = kic.category_id " +
|
||||||
|
" WHERE c2.user_id = HEXTORAW(?) AND (c2.full_path = c.full_path OR c2.full_path LIKE c.full_path || '/%')) AS item_count " +
|
||||||
|
"FROM categories c WHERE c.user_id = HEXTORAW(?) ORDER BY c.full_path",
|
||||||
|
userId, userId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 카테고리 및 하위 카테고리에 속한 knowledge_items 조회.
|
||||||
|
* full_path LIKE 'parent_path%' 로 하위 카테고리를 포함한다.
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> findItemsByCategoryId(String userId, String categoryId) {
|
||||||
|
// 먼저 선택된 카테고리의 full_path를 가져옴
|
||||||
|
var catResult = jdbcTemplate.queryForList(
|
||||||
|
"SELECT full_path FROM categories WHERE RAWTOHEX(id) = ?", categoryId
|
||||||
|
);
|
||||||
|
if (catResult.isEmpty()) return List.of();
|
||||||
|
String fullPath = (String) catResult.get(0).get("FULL_PATH");
|
||||||
|
|
||||||
|
return jdbcTemplate.queryForList(
|
||||||
|
"SELECT DISTINCT RAWTOHEX(ki.id) AS id, ki.type, ki.title, ki.source_url, ki.status, ki.created_at, " +
|
||||||
|
" (SELECT LISTAGG(c2.full_path, ', ') WITHIN GROUP (ORDER BY c2.full_path) " +
|
||||||
|
" FROM knowledge_item_categories kic2 JOIN categories c2 ON c2.id = kic2.category_id " +
|
||||||
|
" WHERE kic2.knowledge_item_id = ki.id) AS categories " +
|
||||||
|
"FROM knowledge_items ki " +
|
||||||
|
"JOIN knowledge_item_categories kic ON kic.knowledge_item_id = ki.id " +
|
||||||
|
"JOIN categories c ON c.id = kic.category_id " +
|
||||||
|
"WHERE ki.user_id = HEXTORAW(?) AND (c.full_path = ? OR c.full_path LIKE ?) " +
|
||||||
|
"ORDER BY ki.created_at DESC",
|
||||||
|
userId, fullPath, fullPath + "/%"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리가 없는 knowledge_items 조회
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> findUncategorizedItems(String userId) {
|
||||||
|
return jdbcTemplate.queryForList(
|
||||||
|
"SELECT RAWTOHEX(ki.id) AS id, ki.type, ki.title, ki.source_url, ki.status, ki.created_at, " +
|
||||||
|
" NULL AS categories " +
|
||||||
|
"FROM knowledge_items ki " +
|
||||||
|
"WHERE ki.user_id = HEXTORAW(?) " +
|
||||||
|
"AND NOT EXISTS (SELECT 1 FROM knowledge_item_categories kic WHERE kic.knowledge_item_id = ki.id) " +
|
||||||
|
"ORDER BY ki.created_at DESC",
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* knowledge_item의 카테고리 매핑 전체 삭제
|
||||||
|
*/
|
||||||
|
public void unlinkAll(String knowledgeItemId) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"DELETE FROM knowledge_item_categories WHERE knowledge_item_id = HEXTORAW(?)",
|
||||||
|
knowledgeItemId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,9 @@ package com.sundol.repository;
|
|||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public class ChatRepository {
|
public class ChatRepository {
|
||||||
|
|
||||||
@@ -12,5 +15,75 @@ public class ChatRepository {
|
|||||||
this.jdbcTemplate = jdbcTemplate;
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: CRUD for chat_sessions, chat_messages
|
public String createSession(String userId, String title) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO chat_sessions (id, user_id, title, created_at, updated_at) " +
|
||||||
|
"VALUES (SYS_GUID(), HEXTORAW(?), ?, SYSTIMESTAMP, SYSTIMESTAMP)",
|
||||||
|
userId, title
|
||||||
|
);
|
||||||
|
var result = jdbcTemplate.queryForList(
|
||||||
|
"SELECT RAWTOHEX(id) AS id FROM chat_sessions WHERE user_id = HEXTORAW(?) " +
|
||||||
|
"ORDER BY created_at DESC FETCH FIRST 1 ROW ONLY",
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
return (String) result.get(0).get("ID");
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Map<String, Object>> listSessions(String userId) {
|
||||||
|
return jdbcTemplate.queryForList(
|
||||||
|
"SELECT RAWTOHEX(id) AS id, title, created_at, updated_at " +
|
||||||
|
"FROM chat_sessions WHERE user_id = HEXTORAW(?) ORDER BY updated_at DESC",
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> findSession(String userId, String sessionId) {
|
||||||
|
var results = jdbcTemplate.queryForList(
|
||||||
|
"SELECT RAWTOHEX(id) AS id, title, created_at, updated_at " +
|
||||||
|
"FROM chat_sessions WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
|
||||||
|
sessionId, userId
|
||||||
|
);
|
||||||
|
return results.isEmpty() ? null : results.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateSessionTitle(String sessionId, String title) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"UPDATE chat_sessions SET title = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
|
||||||
|
title, sessionId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void touchSession(String sessionId) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"UPDATE chat_sessions SET updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
|
||||||
|
sessionId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteSession(String userId, String sessionId) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"DELETE FROM chat_messages WHERE session_id = (SELECT id FROM chat_sessions WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?))",
|
||||||
|
sessionId, userId
|
||||||
|
);
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"DELETE FROM chat_sessions WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
|
||||||
|
sessionId, userId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void insertMessage(String sessionId, String role, String content, String sourceChunksJson, Integer tokensUsed) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO chat_messages (id, session_id, role, content, source_chunks, tokens_used, created_at) " +
|
||||||
|
"VALUES (SYS_GUID(), HEXTORAW(?), ?, ?, ?, ?, SYSTIMESTAMP)",
|
||||||
|
sessionId, role, content, sourceChunksJson, tokensUsed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Map<String, Object>> getMessages(String sessionId) {
|
||||||
|
return jdbcTemplate.queryForList(
|
||||||
|
"SELECT RAWTOHEX(id) AS id, role, content, source_chunks, tokens_used, created_at " +
|
||||||
|
"FROM chat_messages WHERE session_id = HEXTORAW(?) ORDER BY created_at ASC",
|
||||||
|
sessionId
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package com.sundol.repository;
|
||||||
|
|
||||||
|
import oracle.jdbc.OracleType;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.sql.PreparedStatement;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public class ChunkEmbeddingRepository {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
public ChunkEmbeddingRepository(JdbcTemplate jdbcTemplate) {
|
||||||
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void upsertEmbedding(String chunkId, String modelId, float[] embedding) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"DELETE FROM knowledge_chunk_embeddings WHERE chunk_id = HEXTORAW(?) AND model_id = ?",
|
||||||
|
chunkId, modelId
|
||||||
|
);
|
||||||
|
jdbcTemplate.update(con -> {
|
||||||
|
PreparedStatement ps = con.prepareStatement(
|
||||||
|
"INSERT INTO knowledge_chunk_embeddings (id, chunk_id, model_id, embedding, created_at) " +
|
||||||
|
"VALUES (SYS_GUID(), HEXTORAW(?), ?, ?, SYSTIMESTAMP)"
|
||||||
|
);
|
||||||
|
ps.setString(1, chunkId);
|
||||||
|
ps.setString(2, modelId);
|
||||||
|
ps.setObject(3, embedding, OracleType.VECTOR);
|
||||||
|
return ps;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Map<String, Object>> findByChunkId(String chunkId) {
|
||||||
|
return jdbcTemplate.queryForList(
|
||||||
|
"SELECT RAWTOHEX(id) AS id, model_id, created_at " +
|
||||||
|
"FROM knowledge_chunk_embeddings WHERE chunk_id = HEXTORAW(?)",
|
||||||
|
chunkId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 벡터 유사도 검색. 사용자의 knowledge 항목 중 READY 상태인 것만 대상.
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> searchSimilar(String userId, float[] queryEmbedding, int topK) {
|
||||||
|
return jdbcTemplate.query(con -> {
|
||||||
|
PreparedStatement ps = con.prepareStatement(
|
||||||
|
"SELECT RAWTOHEX(c.id) AS chunk_id, c.content, c.chunk_index, c.token_count, " +
|
||||||
|
" RAWTOHEX(ki.id) AS knowledge_item_id, ki.title, ki.type, ki.source_url, " +
|
||||||
|
" VECTOR_DISTANCE(e.embedding, ?, COSINE) AS distance " +
|
||||||
|
"FROM knowledge_chunk_embeddings e " +
|
||||||
|
"JOIN knowledge_chunks c ON c.id = e.chunk_id " +
|
||||||
|
"JOIN knowledge_items ki ON ki.id = c.knowledge_item_id " +
|
||||||
|
"WHERE ki.user_id = HEXTORAW(?) AND ki.status = 'READY' " +
|
||||||
|
"ORDER BY distance ASC " +
|
||||||
|
"FETCH FIRST ? ROWS ONLY"
|
||||||
|
);
|
||||||
|
ps.setObject(1, queryEmbedding, OracleType.VECTOR);
|
||||||
|
ps.setString(2, userId);
|
||||||
|
ps.setInt(3, topK);
|
||||||
|
return ps;
|
||||||
|
}, (rs, rowNum) -> {
|
||||||
|
Map<String, Object> row = new java.util.HashMap<>();
|
||||||
|
row.put("CHUNK_ID", rs.getString("chunk_id"));
|
||||||
|
row.put("CONTENT", rs.getString("content"));
|
||||||
|
row.put("CHUNK_INDEX", rs.getInt("chunk_index"));
|
||||||
|
row.put("TOKEN_COUNT", rs.getInt("token_count"));
|
||||||
|
row.put("KNOWLEDGE_ITEM_ID", rs.getString("knowledge_item_id"));
|
||||||
|
row.put("TITLE", rs.getString("title"));
|
||||||
|
row.put("TYPE", rs.getString("type"));
|
||||||
|
row.put("SOURCE_URL", rs.getString("source_url"));
|
||||||
|
row.put("DISTANCE", rs.getDouble("distance"));
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteByKnowledgeItemId(String knowledgeItemId) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"DELETE FROM knowledge_chunk_embeddings WHERE chunk_id IN " +
|
||||||
|
"(SELECT id FROM knowledge_chunks WHERE knowledge_item_id = HEXTORAW(?))",
|
||||||
|
knowledgeItemId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,9 @@ package com.sundol.repository;
|
|||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public class HabitRepository {
|
public class HabitRepository {
|
||||||
|
|
||||||
@@ -12,5 +15,137 @@ public class HabitRepository {
|
|||||||
this.jdbcTemplate = jdbcTemplate;
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: CRUD for habits, habit_logs
|
public String insert(String userId, String name, String description, String habitType, String targetDays, String color) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO habits (id, user_id, name, description, habit_type, target_days, color, streak_current, streak_best, is_active, created_at, updated_at) " +
|
||||||
|
"VALUES (SYS_GUID(), HEXTORAW(?), ?, ?, ?, ?, ?, 0, 0, 1, SYSTIMESTAMP, SYSTIMESTAMP)",
|
||||||
|
userId, name, description, habitType, targetDays, color
|
||||||
|
);
|
||||||
|
var result = jdbcTemplate.queryForList(
|
||||||
|
"SELECT RAWTOHEX(id) AS id FROM habits WHERE user_id = HEXTORAW(?) ORDER BY created_at DESC FETCH FIRST 1 ROW ONLY",
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
return (String) result.get(0).get("ID");
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Map<String, Object>> list(String userId) {
|
||||||
|
return jdbcTemplate.queryForList(
|
||||||
|
"SELECT RAWTOHEX(id) AS id, name, description, habit_type, target_days, color, " +
|
||||||
|
"streak_current, streak_best, is_active, created_at, updated_at " +
|
||||||
|
"FROM habits WHERE user_id = HEXTORAW(?) AND is_active = 1 ORDER BY created_at ASC",
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> findById(String userId, String id) {
|
||||||
|
var results = jdbcTemplate.queryForList(
|
||||||
|
"SELECT RAWTOHEX(id) AS id, name, description, habit_type, target_days, color, " +
|
||||||
|
"streak_current, streak_best, is_active, created_at, updated_at " +
|
||||||
|
"FROM habits WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
|
||||||
|
id, userId
|
||||||
|
);
|
||||||
|
return results.isEmpty() ? null : results.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateName(String id, String name) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"UPDATE habits SET name = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
|
||||||
|
name, id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateStreak(String id, int current, int best) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"UPDATE habits SET streak_current = ?, streak_best = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
|
||||||
|
current, best, id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deactivate(String id) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"UPDATE habits SET is_active = 0, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
|
||||||
|
id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void delete(String userId, String id) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"DELETE FROM habit_logs WHERE habit_id = (SELECT id FROM habits WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?))",
|
||||||
|
id, userId
|
||||||
|
);
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"DELETE FROM habits WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
|
||||||
|
id, userId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Habit Logs ---
|
||||||
|
|
||||||
|
public void insertLog(String habitId, String note) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO habit_logs (id, habit_id, log_date, checked_in, note, created_at) " +
|
||||||
|
"VALUES (SYS_GUID(), HEXTORAW(?), TRUNC(SYSDATE), 1, ?, SYSTIMESTAMP)",
|
||||||
|
habitId, note
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean hasCheckedInToday(String habitId) {
|
||||||
|
Integer count = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM habit_logs WHERE habit_id = HEXTORAW(?) AND log_date = TRUNC(SYSDATE) AND checked_in = 1",
|
||||||
|
Integer.class, habitId
|
||||||
|
);
|
||||||
|
return count != null && count > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Map<String, Object>> getLogs(String habitId, String from, String to) {
|
||||||
|
StringBuilder sql = new StringBuilder(
|
||||||
|
"SELECT RAWTOHEX(id) AS id, TO_CHAR(log_date, 'YYYY-MM-DD') AS log_date, checked_in, note, created_at " +
|
||||||
|
"FROM habit_logs WHERE habit_id = HEXTORAW(?)"
|
||||||
|
);
|
||||||
|
java.util.ArrayList<Object> params = new java.util.ArrayList<>();
|
||||||
|
params.add(habitId);
|
||||||
|
|
||||||
|
if (from != null && !from.isEmpty()) {
|
||||||
|
sql.append(" AND log_date >= TO_DATE(?, 'YYYY-MM-DD')");
|
||||||
|
params.add(from);
|
||||||
|
}
|
||||||
|
if (to != null && !to.isEmpty()) {
|
||||||
|
sql.append(" AND log_date <= TO_DATE(?, 'YYYY-MM-DD')");
|
||||||
|
params.add(to);
|
||||||
|
}
|
||||||
|
sql.append(" ORDER BY log_date DESC");
|
||||||
|
|
||||||
|
return jdbcTemplate.queryForList(sql.toString(), params.toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연속 체크인 일수 계산 (오늘부터 역순)
|
||||||
|
*/
|
||||||
|
public int calculateCurrentStreak(String habitId) {
|
||||||
|
List<Map<String, Object>> logs = jdbcTemplate.queryForList(
|
||||||
|
"SELECT TO_CHAR(log_date, 'YYYY-MM-DD') AS log_date FROM habit_logs " +
|
||||||
|
"WHERE habit_id = HEXTORAW(?) AND checked_in = 1 ORDER BY log_date DESC",
|
||||||
|
habitId
|
||||||
|
);
|
||||||
|
if (logs.isEmpty()) return 0;
|
||||||
|
|
||||||
|
int streak = 0;
|
||||||
|
java.time.LocalDate expected = java.time.LocalDate.now();
|
||||||
|
|
||||||
|
for (Map<String, Object> log : logs) {
|
||||||
|
java.time.LocalDate logDate = java.time.LocalDate.parse((String) log.get("LOG_DATE"));
|
||||||
|
if (logDate.equals(expected)) {
|
||||||
|
streak++;
|
||||||
|
expected = expected.minusDays(1);
|
||||||
|
} else if (logDate.equals(expected.minusDays(1)) && streak == 0) {
|
||||||
|
// 어제까지 연속이면 (오늘 아직 안 한 경우)
|
||||||
|
expected = logDate;
|
||||||
|
streak++;
|
||||||
|
expected = expected.minusDays(1);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return streak;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ package com.sundol.repository;
|
|||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public class KnowledgeChunkRepository {
|
public class KnowledgeChunkRepository {
|
||||||
|
|
||||||
@@ -12,5 +15,34 @@ public class KnowledgeChunkRepository {
|
|||||||
this.jdbcTemplate = jdbcTemplate;
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: CRUD for knowledge_chunks, VECTOR_DISTANCE search
|
public void insertChunk(String knowledgeItemId, int chunkIndex, String content, int tokenCount) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO knowledge_chunks (id, knowledge_item_id, chunk_index, content, token_count, created_at) " +
|
||||||
|
"VALUES (SYS_GUID(), HEXTORAW(?), ?, ?, ?, SYSTIMESTAMP)",
|
||||||
|
knowledgeItemId, chunkIndex, content, tokenCount
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Map<String, Object>> findByKnowledgeItemId(String knowledgeItemId) {
|
||||||
|
return jdbcTemplate.queryForList(
|
||||||
|
"SELECT RAWTOHEX(id) AS id, chunk_index, content, token_count, created_at " +
|
||||||
|
"FROM knowledge_chunks WHERE knowledge_item_id = HEXTORAW(?) ORDER BY chunk_index",
|
||||||
|
knowledgeItemId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int countByKnowledgeItemId(String knowledgeItemId) {
|
||||||
|
Integer count = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM knowledge_chunks WHERE knowledge_item_id = HEXTORAW(?)",
|
||||||
|
Integer.class, knowledgeItemId
|
||||||
|
);
|
||||||
|
return count != null ? count : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteByKnowledgeItemId(String knowledgeItemId) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"DELETE FROM knowledge_chunks WHERE knowledge_item_id = HEXTORAW(?)",
|
||||||
|
knowledgeItemId
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ package com.sundol.repository;
|
|||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.sql.Clob;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public class KnowledgeRepository {
|
public class KnowledgeRepository {
|
||||||
|
|
||||||
@@ -12,5 +16,113 @@ public class KnowledgeRepository {
|
|||||||
this.jdbcTemplate = jdbcTemplate;
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: CRUD for knowledge_items table
|
public String insert(String userId, String type, String title, String sourceUrl, String rawText) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO knowledge_items (id, user_id, type, title, source_url, raw_text, status, created_at, updated_at) " +
|
||||||
|
"VALUES (SYS_GUID(), HEXTORAW(?), ?, ?, ?, ?, 'PENDING', SYSTIMESTAMP, SYSTIMESTAMP)",
|
||||||
|
userId, type, title, sourceUrl, rawText
|
||||||
|
);
|
||||||
|
// Get the ID of the just-inserted row
|
||||||
|
var result = jdbcTemplate.queryForList(
|
||||||
|
"SELECT RAWTOHEX(id) AS id FROM knowledge_items WHERE user_id = HEXTORAW(?) ORDER BY created_at DESC FETCH FIRST 1 ROW ONLY",
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
return (String) result.get(0).get("ID");
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Map<String, Object>> list(String userId, String type, String status, String search) {
|
||||||
|
StringBuilder sql = new StringBuilder(
|
||||||
|
"SELECT RAWTOHEX(ki.id) AS id, ki.type, ki.title, ki.source_url, ki.status, ki.created_at, ki.updated_at, " +
|
||||||
|
" (SELECT LISTAGG(c.full_path, ', ') WITHIN GROUP (ORDER BY c.full_path) " +
|
||||||
|
" FROM knowledge_item_categories kic JOIN categories c ON c.id = kic.category_id " +
|
||||||
|
" WHERE kic.knowledge_item_id = ki.id) AS categories " +
|
||||||
|
"FROM knowledge_items ki WHERE ki.user_id = HEXTORAW(?)"
|
||||||
|
);
|
||||||
|
java.util.List<Object> params = new java.util.ArrayList<>();
|
||||||
|
params.add(userId);
|
||||||
|
|
||||||
|
if (type != null && !type.isEmpty()) {
|
||||||
|
sql.append(" AND ki.type = ?");
|
||||||
|
params.add(type);
|
||||||
|
}
|
||||||
|
if (status != null && !status.isEmpty()) {
|
||||||
|
sql.append(" AND ki.status = ?");
|
||||||
|
params.add(status);
|
||||||
|
}
|
||||||
|
if (search != null && !search.isEmpty()) {
|
||||||
|
sql.append(" AND UPPER(ki.title) LIKE UPPER(?)");
|
||||||
|
params.add("%" + search + "%");
|
||||||
|
}
|
||||||
|
sql.append(" ORDER BY ki.created_at DESC");
|
||||||
|
|
||||||
|
return jdbcTemplate.queryForList(sql.toString(), params.toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> findById(String userId, String id) {
|
||||||
|
var results = jdbcTemplate.queryForList(
|
||||||
|
"SELECT RAWTOHEX(id) AS id, RAWTOHEX(user_id) AS user_id, type, title, source_url, raw_text, structured_content, status, created_at, updated_at " +
|
||||||
|
"FROM knowledge_items WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
|
||||||
|
id, userId
|
||||||
|
);
|
||||||
|
if (results.isEmpty()) return null;
|
||||||
|
return convertClobFields(results.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> findByIdInternal(String id) {
|
||||||
|
var results = jdbcTemplate.queryForList(
|
||||||
|
"SELECT RAWTOHEX(id) AS id, RAWTOHEX(user_id) AS user_id, type, title, source_url, raw_text, structured_content, status, created_at, updated_at " +
|
||||||
|
"FROM knowledge_items WHERE RAWTOHEX(id) = ?",
|
||||||
|
id
|
||||||
|
);
|
||||||
|
if (results.isEmpty()) return null;
|
||||||
|
return convertClobFields(results.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CLOB 필드를 String으로 변환한다.
|
||||||
|
* Oracle JDBC는 4000바이트 이상의 CLOB을 java.sql.Clob 객체로 반환하므로
|
||||||
|
* API 응답 전에 String으로 변환해야 한다.
|
||||||
|
*/
|
||||||
|
private Map<String, Object> convertClobFields(Map<String, Object> row) {
|
||||||
|
for (var entry : row.entrySet()) {
|
||||||
|
Object val = entry.getValue();
|
||||||
|
if (val instanceof Clob clob) {
|
||||||
|
try {
|
||||||
|
entry.setValue(clob.getSubString(1, (int) clob.length()));
|
||||||
|
} catch (Exception e) {
|
||||||
|
entry.setValue(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateStatus(String id, String status) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"UPDATE knowledge_items SET status = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
|
||||||
|
status, id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateTitle(String id, String title) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"UPDATE knowledge_items SET title = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
|
||||||
|
title, id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateStructuredContent(String id, String structuredContent) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"UPDATE knowledge_items SET structured_content = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
|
||||||
|
new Object[]{ structuredContent, id },
|
||||||
|
new int[]{ java.sql.Types.CLOB, java.sql.Types.VARCHAR }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void delete(String userId, String id) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"DELETE FROM knowledge_items WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
|
||||||
|
id, userId
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ package com.sundol.repository;
|
|||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public class StudyCardRepository {
|
public class StudyCardRepository {
|
||||||
|
|
||||||
@@ -12,5 +15,80 @@ public class StudyCardRepository {
|
|||||||
this.jdbcTemplate = jdbcTemplate;
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: CRUD for study_cards, SM-2 queries
|
public String insert(String userId, String knowledgeItemId, String front, String back) {
|
||||||
|
if (knowledgeItemId != null) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO study_cards (id, user_id, knowledge_item_id, front, back, ease_factor, interval_days, repetitions, next_review_at, created_at, updated_at) " +
|
||||||
|
"VALUES (SYS_GUID(), HEXTORAW(?), HEXTORAW(?), ?, ?, 2.50, 0, 0, SYSTIMESTAMP, SYSTIMESTAMP, SYSTIMESTAMP)",
|
||||||
|
userId, knowledgeItemId, front, back
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO study_cards (id, user_id, knowledge_item_id, front, back, ease_factor, interval_days, repetitions, next_review_at, created_at, updated_at) " +
|
||||||
|
"VALUES (SYS_GUID(), HEXTORAW(?), NULL, ?, ?, 2.50, 0, 0, SYSTIMESTAMP, SYSTIMESTAMP, SYSTIMESTAMP)",
|
||||||
|
userId, front, back
|
||||||
|
);
|
||||||
|
}
|
||||||
|
var result = jdbcTemplate.queryForList(
|
||||||
|
"SELECT RAWTOHEX(id) AS id FROM study_cards WHERE user_id = HEXTORAW(?) ORDER BY created_at DESC FETCH FIRST 1 ROW ONLY",
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
return (String) result.get(0).get("ID");
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Map<String, Object>> getDueCards(String userId) {
|
||||||
|
return jdbcTemplate.queryForList(
|
||||||
|
"SELECT RAWTOHEX(sc.id) AS id, RAWTOHEX(sc.knowledge_item_id) AS knowledge_item_id, " +
|
||||||
|
"sc.front, sc.back, sc.ease_factor, sc.interval_days, sc.repetitions, sc.next_review_at, " +
|
||||||
|
"ki.title AS knowledge_title " +
|
||||||
|
"FROM study_cards sc " +
|
||||||
|
"LEFT JOIN knowledge_items ki ON ki.id = sc.knowledge_item_id " +
|
||||||
|
"WHERE sc.user_id = HEXTORAW(?) AND sc.next_review_at <= SYSTIMESTAMP " +
|
||||||
|
"ORDER BY sc.next_review_at ASC",
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Map<String, Object>> getByKnowledgeItem(String userId, String knowledgeItemId) {
|
||||||
|
return jdbcTemplate.queryForList(
|
||||||
|
"SELECT RAWTOHEX(id) AS id, front, back, ease_factor, interval_days, repetitions, next_review_at, created_at " +
|
||||||
|
"FROM study_cards WHERE user_id = HEXTORAW(?) AND knowledge_item_id = HEXTORAW(?) ORDER BY created_at ASC",
|
||||||
|
userId, knowledgeItemId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> findById(String userId, String id) {
|
||||||
|
var results = jdbcTemplate.queryForList(
|
||||||
|
"SELECT RAWTOHEX(id) AS id, RAWTOHEX(knowledge_item_id) AS knowledge_item_id, " +
|
||||||
|
"front, back, ease_factor, interval_days, repetitions, next_review_at, created_at, updated_at " +
|
||||||
|
"FROM study_cards WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
|
||||||
|
id, userId
|
||||||
|
);
|
||||||
|
return results.isEmpty() ? null : results.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateSm2(String id, double easeFactor, int intervalDays, int repetitions, int nextReviewDaysFromNow) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"UPDATE study_cards SET ease_factor = ?, interval_days = ?, repetitions = ?, " +
|
||||||
|
"next_review_at = SYSTIMESTAMP + INTERVAL '1' DAY * ?, updated_at = SYSTIMESTAMP " +
|
||||||
|
"WHERE RAWTOHEX(id) = ?",
|
||||||
|
easeFactor, intervalDays, repetitions, nextReviewDaysFromNow, id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int countDue(String userId) {
|
||||||
|
Integer count = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM study_cards WHERE user_id = HEXTORAW(?) AND next_review_at <= SYSTIMESTAMP",
|
||||||
|
Integer.class, userId
|
||||||
|
);
|
||||||
|
return count != null ? count : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int countByKnowledgeItem(String knowledgeItemId) {
|
||||||
|
Integer count = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM study_cards WHERE knowledge_item_id = HEXTORAW(?)",
|
||||||
|
Integer.class, knowledgeItemId
|
||||||
|
);
|
||||||
|
return count != null ? count : 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ package com.sundol.repository;
|
|||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public class TagRepository {
|
public class TagRepository {
|
||||||
|
|
||||||
@@ -12,5 +15,83 @@ public class TagRepository {
|
|||||||
this.jdbcTemplate = jdbcTemplate;
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: CRUD for tags, knowledge_item_tags
|
public String insert(String userId, String name, String color) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO tags (id, user_id, name, color, created_at) " +
|
||||||
|
"VALUES (SYS_GUID(), HEXTORAW(?), ?, ?, SYSTIMESTAMP)",
|
||||||
|
userId, name, color
|
||||||
|
);
|
||||||
|
var result = jdbcTemplate.queryForList(
|
||||||
|
"SELECT RAWTOHEX(id) AS id FROM tags WHERE user_id = HEXTORAW(?) ORDER BY created_at DESC FETCH FIRST 1 ROW ONLY",
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
return (String) result.get(0).get("ID");
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Map<String, Object>> list(String userId) {
|
||||||
|
return jdbcTemplate.queryForList(
|
||||||
|
"SELECT RAWTOHEX(t.id) AS id, t.name, t.color, t.created_at, " +
|
||||||
|
"(SELECT COUNT(*) FROM knowledge_item_tags kit WHERE kit.tag_id = t.id) AS item_count " +
|
||||||
|
"FROM tags t WHERE t.user_id = HEXTORAW(?) ORDER BY t.name",
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> findById(String userId, String id) {
|
||||||
|
var results = jdbcTemplate.queryForList(
|
||||||
|
"SELECT RAWTOHEX(id) AS id, name, color, created_at " +
|
||||||
|
"FROM tags WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
|
||||||
|
id, userId
|
||||||
|
);
|
||||||
|
return results.isEmpty() ? null : results.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateName(String id, String name) {
|
||||||
|
jdbcTemplate.update("UPDATE tags SET name = ? WHERE RAWTOHEX(id) = ?", name, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateColor(String id, String color) {
|
||||||
|
jdbcTemplate.update("UPDATE tags SET color = ? WHERE RAWTOHEX(id) = ?", color, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void delete(String userId, String id) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"DELETE FROM knowledge_item_tags WHERE tag_id = (SELECT id FROM tags WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?))",
|
||||||
|
id, userId
|
||||||
|
);
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"DELETE FROM tags WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
|
||||||
|
id, userId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Knowledge Item ↔ Tag 매핑 ---
|
||||||
|
|
||||||
|
public void tagItem(String knowledgeItemId, String tagId) {
|
||||||
|
Integer count = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM knowledge_item_tags WHERE knowledge_item_id = HEXTORAW(?) AND tag_id = HEXTORAW(?)",
|
||||||
|
Integer.class, knowledgeItemId, tagId
|
||||||
|
);
|
||||||
|
if (count != null && count > 0) return;
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO knowledge_item_tags (knowledge_item_id, tag_id) VALUES (HEXTORAW(?), HEXTORAW(?))",
|
||||||
|
knowledgeItemId, tagId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void untagItem(String knowledgeItemId, String tagId) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"DELETE FROM knowledge_item_tags WHERE knowledge_item_id = HEXTORAW(?) AND tag_id = HEXTORAW(?)",
|
||||||
|
knowledgeItemId, tagId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Map<String, Object>> getTagsForItem(String knowledgeItemId) {
|
||||||
|
return jdbcTemplate.queryForList(
|
||||||
|
"SELECT RAWTOHEX(t.id) AS id, t.name, t.color " +
|
||||||
|
"FROM tags t JOIN knowledge_item_tags kit ON kit.tag_id = t.id " +
|
||||||
|
"WHERE kit.knowledge_item_id = HEXTORAW(?) ORDER BY t.name",
|
||||||
|
knowledgeItemId
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ package com.sundol.repository;
|
|||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public class TodoRepository {
|
public class TodoRepository {
|
||||||
|
|
||||||
@@ -12,5 +16,134 @@ public class TodoRepository {
|
|||||||
this.jdbcTemplate = jdbcTemplate;
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: CRUD for todos table
|
public String insert(String userId, String title, String description, String priority, String dueDate, String parentId) {
|
||||||
|
if (parentId != null) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO todos (id, user_id, parent_id, title, description, status, priority, due_date, created_at, updated_at) " +
|
||||||
|
"VALUES (SYS_GUID(), HEXTORAW(?), HEXTORAW(?), ?, ?, 'PENDING', ?, " +
|
||||||
|
"CASE WHEN ? IS NOT NULL THEN TO_DATE(?, 'YYYY-MM-DD') ELSE NULL END, SYSTIMESTAMP, SYSTIMESTAMP)",
|
||||||
|
userId, parentId, title, description, priority, dueDate, dueDate
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO todos (id, user_id, parent_id, title, description, status, priority, due_date, created_at, updated_at) " +
|
||||||
|
"VALUES (SYS_GUID(), HEXTORAW(?), NULL, ?, ?, 'PENDING', ?, " +
|
||||||
|
"CASE WHEN ? IS NOT NULL THEN TO_DATE(?, 'YYYY-MM-DD') ELSE NULL END, SYSTIMESTAMP, SYSTIMESTAMP)",
|
||||||
|
userId, title, description, priority, dueDate, dueDate
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = jdbcTemplate.queryForList(
|
||||||
|
"SELECT RAWTOHEX(id) AS id FROM todos WHERE user_id = HEXTORAW(?) ORDER BY created_at DESC FETCH FIRST 1 ROW ONLY",
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
return (String) result.get(0).get("ID");
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Map<String, Object>> list(String userId, String status, String priority, String dueDate) {
|
||||||
|
StringBuilder sql = new StringBuilder(
|
||||||
|
"SELECT RAWTOHEX(id) AS id, RAWTOHEX(parent_id) AS parent_id, title, description, status, priority, " +
|
||||||
|
"TO_CHAR(due_date, 'YYYY-MM-DD') AS due_date, created_at, updated_at " +
|
||||||
|
"FROM todos WHERE user_id = HEXTORAW(?) AND parent_id IS NULL"
|
||||||
|
);
|
||||||
|
List<Object> params = new ArrayList<>();
|
||||||
|
params.add(userId);
|
||||||
|
|
||||||
|
if (status != null && !status.isEmpty()) {
|
||||||
|
sql.append(" AND status = ?");
|
||||||
|
params.add(status);
|
||||||
|
}
|
||||||
|
if (priority != null && !priority.isEmpty()) {
|
||||||
|
sql.append(" AND priority = ?");
|
||||||
|
params.add(priority);
|
||||||
|
}
|
||||||
|
if (dueDate != null && !dueDate.isEmpty()) {
|
||||||
|
sql.append(" AND due_date <= TO_DATE(?, 'YYYY-MM-DD')");
|
||||||
|
params.add(dueDate);
|
||||||
|
}
|
||||||
|
sql.append(" ORDER BY CASE priority WHEN 'HIGH' THEN 1 WHEN 'MEDIUM' THEN 2 WHEN 'LOW' THEN 3 END, created_at DESC");
|
||||||
|
|
||||||
|
return jdbcTemplate.queryForList(sql.toString(), params.toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> findById(String userId, String id) {
|
||||||
|
var results = jdbcTemplate.queryForList(
|
||||||
|
"SELECT RAWTOHEX(id) AS id, RAWTOHEX(parent_id) AS parent_id, title, description, status, priority, " +
|
||||||
|
"TO_CHAR(due_date, 'YYYY-MM-DD') AS due_date, created_at, updated_at " +
|
||||||
|
"FROM todos WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
|
||||||
|
id, userId
|
||||||
|
);
|
||||||
|
return results.isEmpty() ? null : results.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Map<String, Object>> findSubtasks(String userId, String parentId) {
|
||||||
|
return jdbcTemplate.queryForList(
|
||||||
|
"SELECT RAWTOHEX(id) AS id, RAWTOHEX(parent_id) AS parent_id, title, description, status, priority, " +
|
||||||
|
"TO_CHAR(due_date, 'YYYY-MM-DD') AS due_date, created_at, updated_at " +
|
||||||
|
"FROM todos WHERE parent_id = HEXTORAW(?) AND user_id = HEXTORAW(?) ORDER BY created_at ASC",
|
||||||
|
parentId, userId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateStatus(String id, String status) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"UPDATE todos SET status = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
|
||||||
|
status, id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateTitle(String id, String title) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"UPDATE todos SET title = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
|
||||||
|
title, id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updatePriority(String id, String priority) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"UPDATE todos SET priority = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
|
||||||
|
priority, id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateDueDate(String id, String dueDate) {
|
||||||
|
if (dueDate != null && !dueDate.isEmpty()) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"UPDATE todos SET due_date = TO_DATE(?, 'YYYY-MM-DD'), updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
|
||||||
|
dueDate, id
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"UPDATE todos SET due_date = NULL, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
|
||||||
|
id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void delete(String userId, String id) {
|
||||||
|
// 서브태스크 먼저 삭제
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"DELETE FROM todos WHERE parent_id = HEXTORAW(?) AND user_id = HEXTORAW(?)",
|
||||||
|
id, userId
|
||||||
|
);
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"DELETE FROM todos WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
|
||||||
|
id, userId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int countByUser(String userId, String status) {
|
||||||
|
if (status != null) {
|
||||||
|
Integer count = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM todos WHERE user_id = HEXTORAW(?) AND status = ?",
|
||||||
|
Integer.class, userId, status
|
||||||
|
);
|
||||||
|
return count != null ? count : 0;
|
||||||
|
}
|
||||||
|
Integer count = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM todos WHERE user_id = HEXTORAW(?)",
|
||||||
|
Integer.class, userId
|
||||||
|
);
|
||||||
|
return count != null ? count : 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package com.sundol.repository;
|
|||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Repository;
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
public class UserRepository {
|
public class UserRepository {
|
||||||
|
|
||||||
@@ -12,5 +14,50 @@ public class UserRepository {
|
|||||||
this.jdbcTemplate = jdbcTemplate;
|
this.jdbcTemplate = jdbcTemplate;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: findByGoogleSub, upsert, updateRefreshToken
|
public Map<String, Object> findByGoogleSub(String googleSub) {
|
||||||
|
var results = jdbcTemplate.queryForList(
|
||||||
|
"SELECT RAWTOHEX(id) AS id, email, display_name, avatar_url, google_sub, english_level FROM users WHERE google_sub = ?",
|
||||||
|
googleSub
|
||||||
|
);
|
||||||
|
return results.isEmpty() ? null : results.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> upsert(String email, String displayName, String avatarUrl, String googleSub) {
|
||||||
|
var existing = findByGoogleSub(googleSub);
|
||||||
|
if (existing != null) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"UPDATE users SET display_name = ?, avatar_url = ?, updated_at = SYSTIMESTAMP WHERE google_sub = ?",
|
||||||
|
displayName, avatarUrl, googleSub
|
||||||
|
);
|
||||||
|
return findByGoogleSub(googleSub);
|
||||||
|
} else {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"INSERT INTO users (id, email, display_name, avatar_url, google_sub, created_at, updated_at) VALUES (SYS_GUID(), ?, ?, ?, ?, SYSTIMESTAMP, SYSTIMESTAMP)",
|
||||||
|
email, displayName, avatarUrl, googleSub
|
||||||
|
);
|
||||||
|
return findByGoogleSub(googleSub);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateRefreshToken(String userId, String hashedRefreshToken) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"UPDATE users SET refresh_token = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
|
||||||
|
hashedRefreshToken, userId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> findById(String userId) {
|
||||||
|
var results = jdbcTemplate.queryForList(
|
||||||
|
"SELECT RAWTOHEX(id) AS id, email, display_name, avatar_url, google_sub, refresh_token, english_level FROM users WHERE RAWTOHEX(id) = ?",
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
return results.isEmpty() ? null : results.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void updateEnglishLevel(String userId, String englishLevel) {
|
||||||
|
jdbcTemplate.update(
|
||||||
|
"UPDATE users SET english_level = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
|
||||||
|
englishLevel, userId
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,34 +1,151 @@
|
|||||||
package com.sundol.service;
|
package com.sundol.service;
|
||||||
|
|
||||||
import com.sundol.dto.LoginResponse;
|
import com.sundol.dto.LoginResponse;
|
||||||
|
import com.sundol.exception.AppException;
|
||||||
import com.sundol.repository.UserRepository;
|
import com.sundol.repository.UserRepository;
|
||||||
import com.sundol.security.JwtProvider;
|
import com.sundol.security.JwtProvider;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.reactive.function.client.WebClient;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.core.scheduler.Schedulers;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.util.HexFormat;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class AuthService {
|
public class AuthService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(AuthService.class);
|
||||||
|
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final JwtProvider jwtProvider;
|
private final JwtProvider jwtProvider;
|
||||||
|
private final WebClient webClient;
|
||||||
|
|
||||||
|
@Value("${google.client-id}")
|
||||||
|
private String googleClientId;
|
||||||
|
|
||||||
|
@Value("${google.client-secret}")
|
||||||
|
private String googleClientSecret;
|
||||||
|
|
||||||
|
@Value("${google.redirect-uri}")
|
||||||
|
private String googleRedirectUri;
|
||||||
|
|
||||||
public AuthService(UserRepository userRepository, JwtProvider jwtProvider) {
|
public AuthService(UserRepository userRepository, JwtProvider jwtProvider) {
|
||||||
this.userRepository = userRepository;
|
this.userRepository = userRepository;
|
||||||
this.jwtProvider = jwtProvider;
|
this.jwtProvider = jwtProvider;
|
||||||
|
this.webClient = WebClient.builder().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<LoginResponse> googleLogin(String idToken) {
|
public Mono<LoginResponse> googleLogin(String code) {
|
||||||
// TODO: Verify Google ID token, upsert user, issue JWT pair
|
// Exchange auth code for tokens via Google token endpoint
|
||||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
return webClient.post()
|
||||||
|
.uri("https://oauth2.googleapis.com/token")
|
||||||
|
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
.bodyValue("code=" + code
|
||||||
|
+ "&client_id=" + googleClientId
|
||||||
|
+ "&client_secret=" + googleClientSecret
|
||||||
|
+ "&redirect_uri=" + googleRedirectUri
|
||||||
|
+ "&grant_type=authorization_code")
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(Map.class)
|
||||||
|
.flatMap(tokenResponse -> {
|
||||||
|
String accessToken = (String) tokenResponse.get("access_token");
|
||||||
|
if (accessToken == null) {
|
||||||
|
return Mono.error(new AppException(HttpStatus.UNAUTHORIZED, "Failed to exchange auth code"));
|
||||||
|
}
|
||||||
|
// Get user info from Google
|
||||||
|
return webClient.get()
|
||||||
|
.uri("https://www.googleapis.com/oauth2/v2/userinfo")
|
||||||
|
.header("Authorization", "Bearer " + accessToken)
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(Map.class);
|
||||||
|
})
|
||||||
|
.flatMap(userInfo -> Mono.fromCallable(() -> {
|
||||||
|
String googleSub = (String) userInfo.get("id");
|
||||||
|
String email = (String) userInfo.get("email");
|
||||||
|
String name = (String) userInfo.get("name");
|
||||||
|
String picture = (String) userInfo.get("picture");
|
||||||
|
|
||||||
|
if (googleSub == null || email == null) {
|
||||||
|
throw new AppException(HttpStatus.UNAUTHORIZED, "Invalid Google user info");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert user
|
||||||
|
Map<String, Object> user = userRepository.upsert(email, name, picture, googleSub);
|
||||||
|
String userId = (String) user.get("ID");
|
||||||
|
|
||||||
|
// Create JWT pair
|
||||||
|
String jwt = jwtProvider.createAccessToken(userId, email);
|
||||||
|
String refreshToken = jwtProvider.createRefreshToken(userId, email);
|
||||||
|
|
||||||
|
// Store hashed refresh token
|
||||||
|
userRepository.updateRefreshToken(userId, sha256(refreshToken));
|
||||||
|
|
||||||
|
log.info("User logged in: {} ({})", email, userId);
|
||||||
|
return new LoginResponse(jwt, refreshToken);
|
||||||
|
}).subscribeOn(Schedulers.boundedElastic()));
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<LoginResponse> refresh(String refreshToken) {
|
public Mono<LoginResponse> refresh(String refreshToken) {
|
||||||
// TODO: Validate refresh token, issue new token pair
|
return Mono.fromCallable(() -> {
|
||||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
if (!jwtProvider.validateToken(refreshToken)) {
|
||||||
|
throw new AppException(HttpStatus.UNAUTHORIZED, "Invalid refresh token");
|
||||||
|
}
|
||||||
|
|
||||||
|
var claims = jwtProvider.parseToken(refreshToken);
|
||||||
|
String type = claims.get("type", String.class);
|
||||||
|
if (!"REFRESH".equals(type)) {
|
||||||
|
throw new AppException(HttpStatus.UNAUTHORIZED, "Not a refresh token");
|
||||||
|
}
|
||||||
|
|
||||||
|
String userId = claims.getSubject();
|
||||||
|
Map<String, Object> user = userRepository.findById(userId);
|
||||||
|
if (user == null) {
|
||||||
|
throw new AppException(HttpStatus.UNAUTHORIZED, "User not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify stored hash matches
|
||||||
|
String storedHash = (String) user.get("REFRESH_TOKEN");
|
||||||
|
if (storedHash == null || !storedHash.equals(sha256(refreshToken))) {
|
||||||
|
throw new AppException(HttpStatus.UNAUTHORIZED, "Refresh token revoked");
|
||||||
|
}
|
||||||
|
|
||||||
|
String email = (String) user.get("EMAIL");
|
||||||
|
|
||||||
|
// Issue new pair (rotation)
|
||||||
|
String newAccessToken = jwtProvider.createAccessToken(userId, email);
|
||||||
|
String newRefreshToken = jwtProvider.createRefreshToken(userId, email);
|
||||||
|
userRepository.updateRefreshToken(userId, sha256(newRefreshToken));
|
||||||
|
|
||||||
|
return new LoginResponse(newAccessToken, newRefreshToken);
|
||||||
|
}).subscribeOn(Schedulers.boundedElastic());
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<Void> logout(String userId) {
|
public Mono<Void> logout(String userId) {
|
||||||
// TODO: Invalidate refresh token
|
return Mono.fromRunnable(() -> {
|
||||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
if (userId == null) {
|
||||||
|
log.warn("Logout called with null userId, ignoring");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
userRepository.updateRefreshToken(userId, null);
|
||||||
|
log.info("User logged out: {}", userId);
|
||||||
|
}).subscribeOn(Schedulers.boundedElastic()).then();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String sha256(String input) {
|
||||||
|
try {
|
||||||
|
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||||
|
byte[] hash = md.digest(input.getBytes(StandardCharsets.UTF_8));
|
||||||
|
return HexFormat.of().formatHex(hash);
|
||||||
|
} catch (NoSuchAlgorithmException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +1,186 @@
|
|||||||
package com.sundol.service;
|
package com.sundol.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.sundol.exception.AppException;
|
||||||
import com.sundol.repository.ChatRepository;
|
import com.sundol.repository.ChatRepository;
|
||||||
|
import com.sundol.repository.ChunkEmbeddingRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.core.scheduler.Schedulers;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class ChatService {
|
public class ChatService {
|
||||||
|
|
||||||
private final ChatRepository chatRepository;
|
private static final Logger log = LoggerFactory.getLogger(ChatService.class);
|
||||||
|
private static final int RAG_TOP_K = 5;
|
||||||
|
|
||||||
public ChatService(ChatRepository chatRepository) {
|
private final ChatRepository chatRepository;
|
||||||
|
private final ChunkEmbeddingRepository embeddingRepository;
|
||||||
|
private final OciGenAiService genAiService;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
public ChatService(
|
||||||
|
ChatRepository chatRepository,
|
||||||
|
ChunkEmbeddingRepository embeddingRepository,
|
||||||
|
OciGenAiService genAiService,
|
||||||
|
ObjectMapper objectMapper) {
|
||||||
this.chatRepository = chatRepository;
|
this.chatRepository = chatRepository;
|
||||||
|
this.embeddingRepository = embeddingRepository;
|
||||||
|
this.genAiService = genAiService;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<List<Map<String, Object>>> listSessions(String userId) {
|
public Mono<List<Map<String, Object>>> listSessions(String userId) {
|
||||||
// TODO: List chat sessions for user
|
return Mono.fromCallable(() -> chatRepository.listSessions(userId))
|
||||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
.subscribeOn(Schedulers.boundedElastic());
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<Map<String, Object>> createSession(String userId) {
|
public Mono<Map<String, Object>> createSession(String userId) {
|
||||||
// TODO: Create new chat session
|
return Mono.fromCallable(() -> {
|
||||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
String id = chatRepository.createSession(userId, "New Chat");
|
||||||
|
return chatRepository.findSession(userId, id);
|
||||||
|
}).subscribeOn(Schedulers.boundedElastic());
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<List<Map<String, Object>>> getMessages(String userId, String sessionId) {
|
public Mono<List<Map<String, Object>>> getMessages(String userId, String sessionId) {
|
||||||
// TODO: Get messages for chat session
|
return Mono.fromCallable(() -> {
|
||||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
Map<String, Object> session = chatRepository.findSession(userId, sessionId);
|
||||||
|
if (session == null) {
|
||||||
|
throw new AppException(HttpStatus.NOT_FOUND, "Chat session not found");
|
||||||
|
}
|
||||||
|
return chatRepository.getMessages(sessionId);
|
||||||
|
}).subscribeOn(Schedulers.boundedElastic());
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<Map<String, Object>> sendMessage(String userId, String sessionId, String content) {
|
public Mono<Map<String, Object>> sendMessage(String userId, String sessionId, String content) {
|
||||||
// TODO: RAG pipeline - embed query, search, build prompt, call OCI GenAI
|
return Mono.fromCallable(() -> {
|
||||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
Map<String, Object> session = chatRepository.findSession(userId, sessionId);
|
||||||
|
if (session == null) {
|
||||||
|
throw new AppException(HttpStatus.NOT_FOUND, "Chat session not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 사용자 메시지 저장
|
||||||
|
chatRepository.insertMessage(sessionId, "user", content, null, null);
|
||||||
|
|
||||||
|
// 2. RAG: 쿼리 임베딩 → 유사 청크 검색
|
||||||
|
List<Map<String, Object>> relevantChunks = List.of();
|
||||||
|
String contextBlock = "";
|
||||||
|
if (genAiService.isConfigured()) {
|
||||||
|
try {
|
||||||
|
List<float[]> embeddings = genAiService.embedTexts(List.of(content), "SEARCH_QUERY");
|
||||||
|
relevantChunks = embeddingRepository.searchSimilar(userId, embeddings.get(0), RAG_TOP_K);
|
||||||
|
|
||||||
|
if (!relevantChunks.isEmpty()) {
|
||||||
|
contextBlock = relevantChunks.stream()
|
||||||
|
.map(chunk -> {
|
||||||
|
String title = (String) chunk.get("TITLE");
|
||||||
|
String chunkContent = (String) chunk.get("CONTENT");
|
||||||
|
String source = (String) chunk.get("SOURCE_URL");
|
||||||
|
String header = title != null ? "[" + title + "]" : "[Untitled]";
|
||||||
|
if (source != null) header += " (" + source + ")";
|
||||||
|
return header + "\n" + chunkContent;
|
||||||
|
})
|
||||||
|
.collect(Collectors.joining("\n\n---\n\n"));
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("RAG search failed, proceeding without context", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 대화 히스토리 조회 (최근 20개)
|
||||||
|
List<Map<String, Object>> history = chatRepository.getMessages(sessionId);
|
||||||
|
int historyStart = Math.max(0, history.size() - 20);
|
||||||
|
List<Map<String, Object>> recentHistory = history.subList(historyStart, history.size());
|
||||||
|
|
||||||
|
// 4. LLM 프롬프트 구성
|
||||||
|
String systemMsg = buildSystemPrompt(contextBlock);
|
||||||
|
String userMsg = buildUserPrompt(recentHistory, content);
|
||||||
|
|
||||||
|
// 5. LLM 호출
|
||||||
|
String assistantResponse;
|
||||||
|
try {
|
||||||
|
assistantResponse = genAiService.chat(systemMsg, userMsg, null);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("LLM chat failed", e);
|
||||||
|
assistantResponse = "죄송합니다, 응답을 생성하는 중 오류가 발생했습니다.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 응답 저장
|
||||||
|
String sourceChunksJson = null;
|
||||||
|
if (!relevantChunks.isEmpty()) {
|
||||||
|
try {
|
||||||
|
List<Map<String, Object>> sources = relevantChunks.stream()
|
||||||
|
.map(c -> Map.of(
|
||||||
|
"knowledgeItemId", (Object) c.get("KNOWLEDGE_ITEM_ID"),
|
||||||
|
"title", c.get("TITLE") != null ? c.get("TITLE") : "Untitled",
|
||||||
|
"chunkIndex", c.get("CHUNK_INDEX"),
|
||||||
|
"distance", c.get("DISTANCE")
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
sourceChunksJson = objectMapper.writeValueAsString(sources);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to serialize source chunks", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chatRepository.insertMessage(sessionId, "assistant", assistantResponse, sourceChunksJson, null);
|
||||||
|
|
||||||
|
// 7. 첫 메시지면 세션 제목 업데이트
|
||||||
|
if (history.size() <= 1) {
|
||||||
|
String sessionTitle = content.length() > 50
|
||||||
|
? content.substring(0, 50) + "..." : content;
|
||||||
|
chatRepository.updateSessionTitle(sessionId, sessionTitle);
|
||||||
|
}
|
||||||
|
chatRepository.touchSession(sessionId);
|
||||||
|
|
||||||
|
return Map.of(
|
||||||
|
"role", (Object) "assistant",
|
||||||
|
"content", assistantResponse,
|
||||||
|
"sourceChunks", sourceChunksJson != null ? sourceChunksJson : "[]"
|
||||||
|
);
|
||||||
|
}).subscribeOn(Schedulers.boundedElastic());
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildSystemPrompt(String contextBlock) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("You are SUNDOL, a helpful personal knowledge assistant. ");
|
||||||
|
sb.append("Answer questions based on the user's knowledge base. ");
|
||||||
|
sb.append("If the context contains relevant information, use it to answer accurately. ");
|
||||||
|
sb.append("If the context doesn't contain relevant information, say so honestly. ");
|
||||||
|
sb.append("Respond in the same language the user uses.");
|
||||||
|
|
||||||
|
if (!contextBlock.isBlank()) {
|
||||||
|
sb.append("\n\n--- Knowledge Context ---\n\n");
|
||||||
|
sb.append(contextBlock);
|
||||||
|
sb.append("\n\n--- End Context ---");
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildUserPrompt(List<Map<String, Object>> history, String currentMessage) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
// 최근 대화 히스토리를 포함하여 맥락 유지 (현재 메시지 제외)
|
||||||
|
for (int i = 0; i < history.size() - 1; i++) {
|
||||||
|
Map<String, Object> msg = history.get(i);
|
||||||
|
String role = (String) msg.get("ROLE");
|
||||||
|
Object contentObj = msg.get("CONTENT");
|
||||||
|
String content = contentObj != null ? contentObj.toString() : "";
|
||||||
|
sb.append(role.equals("user") ? "User: " : "Assistant: ");
|
||||||
|
sb.append(content).append("\n\n");
|
||||||
|
}
|
||||||
|
sb.append(currentMessage);
|
||||||
|
return sb.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<Void> deleteSession(String userId, String sessionId) {
|
public Mono<Void> deleteSession(String userId, String sessionId) {
|
||||||
// TODO: Delete chat session and messages
|
return Mono.fromRunnable(() -> chatRepository.deleteSession(userId, sessionId))
|
||||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
.subscribeOn(Schedulers.boundedElastic()).then();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.sundol.service;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class ChunkingService {
|
||||||
|
|
||||||
|
private static final int CHUNK_SIZE_TOKENS = 500;
|
||||||
|
private static final int CHUNK_OVERLAP_TOKENS = 50;
|
||||||
|
|
||||||
|
public List<String> chunk(String text) {
|
||||||
|
if (text == null || text.isBlank()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
String[] words = text.split("\\s+");
|
||||||
|
int chunkWords = (int) (CHUNK_SIZE_TOKENS * 0.75);
|
||||||
|
int overlapWords = (int) (CHUNK_OVERLAP_TOKENS * 0.75);
|
||||||
|
|
||||||
|
List<String> chunks = new ArrayList<>();
|
||||||
|
int i = 0;
|
||||||
|
while (i < words.length) {
|
||||||
|
int end = Math.min(i + chunkWords, words.length);
|
||||||
|
chunks.add(String.join(" ", java.util.Arrays.copyOfRange(words, i, end)));
|
||||||
|
i += chunkWords - overlapWords;
|
||||||
|
}
|
||||||
|
return chunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int estimateTokenCount(String text) {
|
||||||
|
if (text == null || text.isBlank()) return 0;
|
||||||
|
return (int) (text.split("\\s+").length / 0.75);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
package com.sundol.service;
|
package com.sundol.service;
|
||||||
|
|
||||||
import com.sundol.dto.HabitRequest;
|
import com.sundol.dto.HabitRequest;
|
||||||
|
import com.sundol.exception.AppException;
|
||||||
import com.sundol.repository.HabitRepository;
|
import com.sundol.repository.HabitRepository;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.core.scheduler.Schedulers;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -18,32 +21,85 @@ public class HabitService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Mono<List<Map<String, Object>>> list(String userId) {
|
public Mono<List<Map<String, Object>>> list(String userId) {
|
||||||
// TODO: List habits for user
|
return Mono.fromCallable(() -> {
|
||||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
List<Map<String, Object>> habits = habitRepository.list(userId);
|
||||||
|
for (Map<String, Object> habit : habits) {
|
||||||
|
String habitId = (String) habit.get("ID");
|
||||||
|
habit.put("CHECKED_TODAY", habitRepository.hasCheckedInToday(habitId));
|
||||||
|
}
|
||||||
|
return habits;
|
||||||
|
}).subscribeOn(Schedulers.boundedElastic());
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<Map<String, Object>> create(String userId, HabitRequest request) {
|
public Mono<Map<String, Object>> create(String userId, HabitRequest request) {
|
||||||
// TODO: Create habit
|
return Mono.fromCallable(() -> {
|
||||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
String habitType = request.habitType() != null ? request.habitType() : "DAILY";
|
||||||
|
String color = request.color() != null ? request.color() : "#6366f1";
|
||||||
|
String id = habitRepository.insert(
|
||||||
|
userId, request.name(), request.description(),
|
||||||
|
habitType, request.targetDays(), color
|
||||||
|
);
|
||||||
|
return habitRepository.findById(userId, id);
|
||||||
|
}).subscribeOn(Schedulers.boundedElastic());
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<Map<String, Object>> update(String userId, String id, Map<String, Object> updates) {
|
public Mono<Map<String, Object>> update(String userId, String id, Map<String, Object> updates) {
|
||||||
// TODO: Update habit
|
return Mono.fromCallable(() -> {
|
||||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
Map<String, Object> habit = habitRepository.findById(userId, id);
|
||||||
|
if (habit == null) {
|
||||||
|
throw new AppException(HttpStatus.NOT_FOUND, "Habit not found");
|
||||||
|
}
|
||||||
|
if (updates.containsKey("name")) {
|
||||||
|
habitRepository.updateName(id, (String) updates.get("name"));
|
||||||
|
}
|
||||||
|
return habitRepository.findById(userId, id);
|
||||||
|
}).subscribeOn(Schedulers.boundedElastic());
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<Void> delete(String userId, String id) {
|
public Mono<Void> delete(String userId, String id) {
|
||||||
// TODO: Delete habit and logs
|
return Mono.fromRunnable(() -> {
|
||||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
Map<String, Object> habit = habitRepository.findById(userId, id);
|
||||||
|
if (habit == null) {
|
||||||
|
throw new AppException(HttpStatus.NOT_FOUND, "Habit not found");
|
||||||
|
}
|
||||||
|
habitRepository.delete(userId, id);
|
||||||
|
}).subscribeOn(Schedulers.boundedElastic()).then();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<Map<String, Object>> checkin(String userId, String id, String note) {
|
public Mono<Map<String, Object>> checkin(String userId, String id, String note) {
|
||||||
// TODO: Check in for today
|
return Mono.fromCallable(() -> {
|
||||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
Map<String, Object> habit = habitRepository.findById(userId, id);
|
||||||
|
if (habit == null) {
|
||||||
|
throw new AppException(HttpStatus.NOT_FOUND, "Habit not found");
|
||||||
|
}
|
||||||
|
if (habitRepository.hasCheckedInToday(id)) {
|
||||||
|
throw new AppException(HttpStatus.CONFLICT, "Already checked in today");
|
||||||
|
}
|
||||||
|
|
||||||
|
habitRepository.insertLog(id, note);
|
||||||
|
|
||||||
|
// 스트릭 갱신
|
||||||
|
int currentStreak = habitRepository.calculateCurrentStreak(id);
|
||||||
|
Number bestStreakNum = (Number) habit.get("STREAK_BEST");
|
||||||
|
int bestStreak = bestStreakNum != null ? bestStreakNum.intValue() : 0;
|
||||||
|
if (currentStreak > bestStreak) {
|
||||||
|
bestStreak = currentStreak;
|
||||||
|
}
|
||||||
|
habitRepository.updateStreak(id, currentStreak, bestStreak);
|
||||||
|
|
||||||
|
Map<String, Object> updated = habitRepository.findById(userId, id);
|
||||||
|
updated.put("CHECKED_TODAY", true);
|
||||||
|
return updated;
|
||||||
|
}).subscribeOn(Schedulers.boundedElastic());
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<List<Map<String, Object>>> getLogs(String userId, String id, String from, String to) {
|
public Mono<List<Map<String, Object>>> getLogs(String userId, String id, String from, String to) {
|
||||||
// TODO: Get habit logs
|
return Mono.fromCallable(() -> {
|
||||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
Map<String, Object> habit = habitRepository.findById(userId, id);
|
||||||
|
if (habit == null) {
|
||||||
|
throw new AppException(HttpStatus.NOT_FOUND, "Habit not found");
|
||||||
|
}
|
||||||
|
return habitRepository.getLogs(id, from, to);
|
||||||
|
}).subscribeOn(Schedulers.boundedElastic());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,571 @@
|
|||||||
|
package com.sundol.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.sundol.repository.CategoryRepository;
|
||||||
|
import com.sundol.repository.ChunkEmbeddingRepository;
|
||||||
|
import com.sundol.repository.KnowledgeChunkRepository;
|
||||||
|
import com.sundol.repository.KnowledgeRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class IngestPipelineService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(IngestPipelineService.class);
|
||||||
|
|
||||||
|
private final KnowledgeRepository knowledgeRepository;
|
||||||
|
private final KnowledgeChunkRepository chunkRepository;
|
||||||
|
private final ChunkEmbeddingRepository embeddingRepository;
|
||||||
|
private final CategoryRepository categoryRepository;
|
||||||
|
private final com.sundol.repository.UserRepository userRepository;
|
||||||
|
private final ChunkingService chunkingService;
|
||||||
|
private final WebCrawlerService webCrawlerService;
|
||||||
|
private final OciGenAiService genAiService;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
public IngestPipelineService(
|
||||||
|
KnowledgeRepository knowledgeRepository,
|
||||||
|
KnowledgeChunkRepository chunkRepository,
|
||||||
|
ChunkEmbeddingRepository embeddingRepository,
|
||||||
|
CategoryRepository categoryRepository,
|
||||||
|
com.sundol.repository.UserRepository userRepository,
|
||||||
|
ChunkingService chunkingService,
|
||||||
|
WebCrawlerService webCrawlerService,
|
||||||
|
OciGenAiService genAiService,
|
||||||
|
ObjectMapper objectMapper) {
|
||||||
|
this.knowledgeRepository = knowledgeRepository;
|
||||||
|
this.chunkRepository = chunkRepository;
|
||||||
|
this.embeddingRepository = embeddingRepository;
|
||||||
|
this.categoryRepository = categoryRepository;
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
this.chunkingService = chunkingService;
|
||||||
|
this.webCrawlerService = webCrawlerService;
|
||||||
|
this.genAiService = genAiService;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final int TITLE_MAX_LENGTH = 80;
|
||||||
|
private static final int TEXT_PREVIEW_LENGTH = 3000;
|
||||||
|
private static final int STRUCTURING_MIN_LENGTH = 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LLM으로 콘텐츠를 구조화: Abstract + 목차 + 목차별 상세 정리.
|
||||||
|
* 1000자 이상일 때만 실행.
|
||||||
|
* 1차 호출: Abstract + 목차 생성, 2차~ 호출: 목차별 상세 정리, 최종 조합.
|
||||||
|
*/
|
||||||
|
public String structureContent(String text, String modelId, String knowledgeItemId) {
|
||||||
|
return structureContent(text, modelId, knowledgeItemId, "B2");
|
||||||
|
}
|
||||||
|
|
||||||
|
public String structureContent(String text, String modelId, String knowledgeItemId, String englishLevel) {
|
||||||
|
if (!genAiService.isConfigured()) {
|
||||||
|
log.info("OCI GenAI not configured, skipping structuring");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text.length() < STRUCTURING_MIN_LENGTH) {
|
||||||
|
log.info("Content too short for structuring ({} chars), skipping", text.length());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
String content = text.length() > 30000 ? text.substring(0, 30000) : text;
|
||||||
|
boolean isEnglish = isEnglishContent(content);
|
||||||
|
String level = (englishLevel == null || englishLevel.isBlank()) ? "B2" : englishLevel;
|
||||||
|
log.info("Structuring content: isEnglish={}, englishLevel={}", isEnglish, level);
|
||||||
|
|
||||||
|
// === 1차 호출: Abstract + 목차 생성 ===
|
||||||
|
String tocSystemMsg = buildTocSystemMsg(isEnglish);
|
||||||
|
String tocUserMsg = "아래 원본 텍스트의 요약과 목차를 생성해주세요:\n\n" + content;
|
||||||
|
String tocResult = genAiService.chat(tocSystemMsg, tocUserMsg, modelId).strip();
|
||||||
|
log.info("Phase 1 - TOC generated: {} chars", tocResult.length());
|
||||||
|
|
||||||
|
// 1차 결과 중간 저장
|
||||||
|
if (knowledgeItemId != null) {
|
||||||
|
knowledgeRepository.updateStructuredContent(knowledgeItemId, tocResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 목차 항목 파싱
|
||||||
|
List<String> tocItems = parseTocItems(tocResult);
|
||||||
|
if (tocItems.isEmpty()) {
|
||||||
|
log.warn("No TOC items parsed, returning TOC-only result");
|
||||||
|
return tocResult;
|
||||||
|
}
|
||||||
|
log.info("Parsed {} TOC items: {}", tocItems.size(), tocItems);
|
||||||
|
|
||||||
|
// === 2차~ 호출: 목차별 상세 정리 (잘리면 이어쓰기) ===
|
||||||
|
StringBuilder fullResult = new StringBuilder(tocResult).append("\n\n");
|
||||||
|
String sectionSystemMsg = buildSectionSystemMsg(isEnglish, level);
|
||||||
|
|
||||||
|
for (int i = 0; i < tocItems.size(); i++) {
|
||||||
|
String tocItem = tocItems.get(i);
|
||||||
|
int sectionNum = i + 1;
|
||||||
|
log.info("Phase 2 - Processing section {}: '{}'", sectionNum, tocItem);
|
||||||
|
|
||||||
|
try {
|
||||||
|
String sectionUserMsg = "원본 텍스트에서 아래 섹션에 해당하는 내용을 상세히 정리해주세요.\n" +
|
||||||
|
"응답이 잘릴 경우 문장 중간에 끊지 말고, 완성된 문장까지만 작성하세요.\n\n" +
|
||||||
|
"## 정리할 섹션\n" + sectionNum + ". " + tocItem + "\n\n" +
|
||||||
|
"## 원본 텍스트\n" + content;
|
||||||
|
|
||||||
|
String sectionResult = genAiService.chat(sectionSystemMsg, sectionUserMsg, modelId).strip();
|
||||||
|
fullResult.append(sectionResult).append("\n\n");
|
||||||
|
log.info("Phase 2 - Section {} '{}' generated: {} chars", sectionNum, tocItem, sectionResult.length());
|
||||||
|
|
||||||
|
// 응답이 잘린 것 같으면 이어쓰기 (최대 2회)
|
||||||
|
for (int cont = 0; cont < 2; cont++) {
|
||||||
|
if (sectionResult.length() < 3500) break;
|
||||||
|
String lastChars = sectionResult.substring(Math.max(0, sectionResult.length() - 50));
|
||||||
|
if (lastChars.endsWith(".") || lastChars.endsWith("다.") || lastChars.endsWith("니다.") ||
|
||||||
|
lastChars.endsWith("음.") || lastChars.endsWith("임.") || lastChars.endsWith("함.") ||
|
||||||
|
lastChars.endsWith("습니다.") || lastChars.endsWith("됩니다.")) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Phase 2 - Section {} may be truncated, continuation {}", sectionNum, cont + 1);
|
||||||
|
String contUserMsg = "이전 응답이 잘렸습니다. 아래 마지막 부분부터 이어서 작성해주세요.\n" +
|
||||||
|
"이미 작성된 내용을 반복하지 마세요.\n\n" +
|
||||||
|
"## 이전 응답의 마지막 200자\n" +
|
||||||
|
sectionResult.substring(Math.max(0, sectionResult.length() - 200)) + "\n\n" +
|
||||||
|
"## 정리할 섹션\n" + sectionNum + ". " + tocItem + "\n\n" +
|
||||||
|
"## 원본 텍스트\n" + content;
|
||||||
|
|
||||||
|
String contResult = genAiService.chat(sectionSystemMsg, contUserMsg, modelId).strip();
|
||||||
|
if (contResult.isBlank()) break;
|
||||||
|
|
||||||
|
fullResult.append(contResult).append("\n\n");
|
||||||
|
sectionResult = contResult;
|
||||||
|
log.info("Phase 2 - Section {} continuation {}: {} chars", sectionNum, cont + 1, contResult.length());
|
||||||
|
|
||||||
|
if (knowledgeItemId != null) {
|
||||||
|
knowledgeRepository.updateStructuredContent(knowledgeItemId, fullResult.toString().strip());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to process section {}: {}", sectionNum, e.getMessage());
|
||||||
|
fullResult.append("# ").append(sectionNum).append(". ").append(tocItem).append("\n\n(정리 실패)\n\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 섹션 완료마다 중간 저장
|
||||||
|
if (knowledgeItemId != null) {
|
||||||
|
knowledgeRepository.updateStructuredContent(knowledgeItemId, fullResult.toString().strip());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String result = fullResult.toString().strip();
|
||||||
|
log.info("Structured content generated: {} chars ({} sections)", result.length(), tocItems.size());
|
||||||
|
return result;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Content structuring failed", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 영어 컨텐츠인지 판단한다 (ASCII 알파벳 비중 60% 이상).
|
||||||
|
*/
|
||||||
|
private boolean isEnglishContent(String text) {
|
||||||
|
if (text == null || text.isBlank()) return false;
|
||||||
|
int alpha = 0, total = 0;
|
||||||
|
for (int i = 0; i < text.length(); i++) {
|
||||||
|
char c = text.charAt(i);
|
||||||
|
if (Character.isWhitespace(c)) continue;
|
||||||
|
total++;
|
||||||
|
if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) alpha++;
|
||||||
|
}
|
||||||
|
if (total == 0) return false;
|
||||||
|
double ratio = (double) alpha / total;
|
||||||
|
return ratio >= 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CEFR 레벨에 대한 설명을 반환한다 (TOEIC 매핑 포함).
|
||||||
|
*/
|
||||||
|
private String cefrDescription(String level) {
|
||||||
|
return switch (level) {
|
||||||
|
case "A1" -> "A1 (TOEIC 120-225, 입문)";
|
||||||
|
case "A2" -> "A2 (TOEIC 225-550, 초급, 기초 회화)";
|
||||||
|
case "B1" -> "B1 (TOEIC 550-785, 중급, 일상 의사소통)";
|
||||||
|
case "B2" -> "B2 (TOEIC 785-945, 중상급, 업무 영어)";
|
||||||
|
case "C1" -> "C1 (TOEIC 945-990, 고급, 유창함)";
|
||||||
|
case "C2" -> "C2 (원어민 수준)";
|
||||||
|
default -> "B2 (TOEIC 785-945, 중상급)";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1차 호출용 시스템 메시지 (Abstract + 목차).
|
||||||
|
*/
|
||||||
|
private String buildTocSystemMsg(boolean isEnglish) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("당신은 콘텐츠 분석 전문가입니다. 주어진 원본 텍스트를 분석하여 요약과 목차만 생성해주세요.\n\n");
|
||||||
|
sb.append("## 출력 형식 규칙\n");
|
||||||
|
sb.append("1. 한국어로 작성하세요 (원본이 영어여도).\n");
|
||||||
|
sb.append("2. 순수 Markdown 형식으로 작성하세요. 코드 블록(```)으로 감싸지 마세요.\n");
|
||||||
|
sb.append("3. 아래 구조를 정확히 따르세요:\n\n");
|
||||||
|
sb.append("# 요약 (Abstract)\n");
|
||||||
|
sb.append("(핵심 내용을 3~5문장으로 요약)\n\n");
|
||||||
|
sb.append("# 목차\n");
|
||||||
|
sb.append("1. 첫 번째 주제\n");
|
||||||
|
sb.append("2. 두 번째 주제\n");
|
||||||
|
sb.append("...\n\n");
|
||||||
|
sb.append("## 작성 규칙\n");
|
||||||
|
sb.append("- 목차는 원본 텍스트의 **서술 순서(흐름)**에 따라 나누세요. 주제별 분류가 아닙니다.\n");
|
||||||
|
sb.append("- 원본에서 앞부분에 나오는 내용이 1번, 뒷부분이 마지막 번호가 되어야 합니다.\n");
|
||||||
|
sb.append("- 각 목차 항목이 원본의 서로 다른 구간을 담당해야 합니다. 같은 내용이 여러 항목에 걸치면 안 됩니다.\n");
|
||||||
|
sb.append("- 목차 개수는 원본 길이에 비례하세요: 3000자 미만이면 3~5개, 3000~10000자이면 5~10개, 10000자 이상이면 8~15개.\n");
|
||||||
|
sb.append("- 목차 항목은 번호와 제목만 포함하세요. 상세 설명은 넣지 마세요.\n");
|
||||||
|
sb.append("- 원본에 없는 내용을 추가하지 마세요.\n");
|
||||||
|
sb.append("- 목차 항목 앞에 불릿 마커(`-`, `*`)를 붙이지 마세요. 숫자만 사용하세요.\n");
|
||||||
|
sb.append("- **금지**: 전체 요약, 결론, 마무리, 종합 정리 같은 성격의 섹션을 만들지 마세요. 각 섹션은 원본의 고유한 구간만 담당해야 합니다.\n");
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 2차+ 호출용 시스템 메시지 (목차별 상세 정리).
|
||||||
|
* 영어 컨텐츠인 경우 영어 표현 학습 섹션 추가.
|
||||||
|
*/
|
||||||
|
private String buildSectionSystemMsg(boolean isEnglish, String englishLevel) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("당신은 콘텐츠 정리 전문가입니다. 주어진 원본 텍스트에서 지정된 섹션에 해당하는 내용만 상세히 정리해주세요.\n\n");
|
||||||
|
|
||||||
|
sb.append("## 출력 형식 규칙 (반드시 지킬 것)\n");
|
||||||
|
sb.append("1. 순수 Markdown 형식으로 작성하세요. 코드 블록(```)으로 감싸지 마세요.\n");
|
||||||
|
sb.append("2. 섹션 제목은 정확히 `# 번호. 제목` 형식으로 시작하세요. (예: `# 1. 첫 번째 주제`)\n");
|
||||||
|
sb.append("3. 하위 제목이 필요하면 `## 소제목` 또는 `### 세부제목`을 사용하세요.\n");
|
||||||
|
sb.append("4. 불릿 포인트는 `-`만 사용하세요. `*`는 사용하지 마세요.\n");
|
||||||
|
sb.append("5. 굵은 글씨는 `**텍스트**` 형식만 사용하세요.\n");
|
||||||
|
sb.append("6. 각 불릿 포인트는 한 줄로 시작하고, 들여쓰기는 2칸(공백 2개)으로 일관되게 하세요.\n");
|
||||||
|
sb.append("7. 빈 줄은 섹션/소제목 구분에만 사용하세요. 불릿 사이에 빈 줄을 넣지 마세요.\n");
|
||||||
|
sb.append("8. 한국어로 작성하세요 (원본이 영어여도).\n\n");
|
||||||
|
|
||||||
|
sb.append("## 내용 작성 규칙\n");
|
||||||
|
sb.append("- 원본 텍스트에 있는 내용만 정리하세요. 원본에 없는 내용을 절대 추가하지 마세요.\n");
|
||||||
|
sb.append("- 부연 설명, 해석, 의견을 추가하지 마세요. 요약과 정리만 하세요.\n");
|
||||||
|
sb.append("- 원본의 의미를 왜곡하지 마세요. 디테일을 살리되, 원본의 범위를 넘지 마세요.\n");
|
||||||
|
sb.append("- 해당 섹션이 담당하는 원본 구간의 내용만 정리하세요. 다른 섹션의 내용을 포함하지 마세요.\n");
|
||||||
|
sb.append("- 가독성을 위해 하위 항목, 굵은 글씨, 불릿 포인트를 적절히 활용하세요.\n");
|
||||||
|
sb.append("- 원본이 짧으면 정리도 짧게 하세요. 불필요하게 늘리지 마세요.\n");
|
||||||
|
|
||||||
|
if (isEnglish) {
|
||||||
|
sb.append("\n## 영어 학습 보조 (원본이 영어 컨텐츠임)\n");
|
||||||
|
sb.append("사용자의 영어 수준: **").append(cefrDescription(englishLevel)).append("**\n\n");
|
||||||
|
sb.append("이 섹션 정리 마지막에 다음 형식으로 영어 학습 박스를 추가하세요:\n\n");
|
||||||
|
sb.append("### 핵심 영어 표현\n");
|
||||||
|
sb.append("- **expression** (한글 번역) — 간단한 설명 또는 사용 맥락\n");
|
||||||
|
sb.append(" - 예문: \"Original English sentence from the text\" (한글 번역)\n\n");
|
||||||
|
sb.append("### 작성 규칙:\n");
|
||||||
|
sb.append("- 사용자의 수준(").append(englishLevel).append(")보다 약간 어려운 표현을 우선 추출하세요.\n");
|
||||||
|
sb.append("- 일상적인 단어(go, eat, see 등)는 추출하지 마세요.\n");
|
||||||
|
sb.append("- 학습할 가치가 있는 관용 표현, 숙어, 학술/전문 용어, 콜로케이션을 우선하세요.\n");
|
||||||
|
sb.append("- 섹션당 3~7개 표현을 추출하세요.\n");
|
||||||
|
sb.append("- 예문은 반드시 원본 텍스트에서 인용하세요.\n");
|
||||||
|
sb.append("- 추출할 만한 표현이 없으면 이 박스를 생략하세요.\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 목차 텍스트에서 항목들을 파싱한다.
|
||||||
|
* "1. 첫 번째 주제" 형태의 줄을 추출.
|
||||||
|
*/
|
||||||
|
private List<String> parseTocItems(String tocText) {
|
||||||
|
List<String> items = new java.util.ArrayList<>();
|
||||||
|
java.util.regex.Pattern pattern = java.util.regex.Pattern.compile("^\\d+\\.\\s+(.+)$", java.util.regex.Pattern.MULTILINE);
|
||||||
|
java.util.regex.Matcher matcher = pattern.matcher(tocText);
|
||||||
|
|
||||||
|
// "# 목차" 이후의 내용만 파싱
|
||||||
|
int tocStart = tocText.indexOf("# 목차");
|
||||||
|
if (tocStart == -1) tocStart = tocText.indexOf("# Table of Contents");
|
||||||
|
if (tocStart == -1) tocStart = 0;
|
||||||
|
String tocSection = tocText.substring(tocStart);
|
||||||
|
|
||||||
|
// 목차 섹션 이후 다음 '#'이 나오기 전까지만 파싱
|
||||||
|
int nextSection = tocSection.indexOf("\n#", 2);
|
||||||
|
if (nextSection > 0) {
|
||||||
|
tocSection = tocSection.substring(0, nextSection);
|
||||||
|
}
|
||||||
|
|
||||||
|
matcher = pattern.matcher(tocSection);
|
||||||
|
while (matcher.find()) {
|
||||||
|
String item = matcher.group(1).strip();
|
||||||
|
if (!item.isBlank()) {
|
||||||
|
items.add(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LLM으로 내용 기반 제목 생성. 실패 시 텍스트 앞부분으로 폴백.
|
||||||
|
*/
|
||||||
|
private String generateTitle(String text, String modelId) {
|
||||||
|
if (genAiService.isConfigured()) {
|
||||||
|
try {
|
||||||
|
String preview = text.length() > TEXT_PREVIEW_LENGTH
|
||||||
|
? text.substring(0, TEXT_PREVIEW_LENGTH) : text;
|
||||||
|
|
||||||
|
String systemMsg = "You are a helpful assistant that generates concise titles. " +
|
||||||
|
"Respond with ONLY the title, nothing else. " +
|
||||||
|
"The title should be in the same language as the content. " +
|
||||||
|
"Keep it under 60 characters.";
|
||||||
|
String userMsg = "Generate a concise, descriptive title for the following content:\n\n" + preview;
|
||||||
|
|
||||||
|
String title = genAiService.chat(systemMsg, userMsg, modelId).strip();
|
||||||
|
// 따옴표 제거
|
||||||
|
title = title.replaceAll("^\"|\"$", "").replaceAll("^'|'$", "");
|
||||||
|
if (!title.isBlank() && title.length() <= TITLE_MAX_LENGTH) {
|
||||||
|
log.info("LLM generated title: {}", title);
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("LLM title generation failed, falling back to text truncation", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: 텍스트 첫 줄
|
||||||
|
String firstLine = text.strip().split("\\r?\\n", 2)[0].strip();
|
||||||
|
if (firstLine.length() <= TITLE_MAX_LENGTH) {
|
||||||
|
return firstLine;
|
||||||
|
}
|
||||||
|
return firstLine.substring(0, TITLE_MAX_LENGTH - 3) + "...";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LLM으로 2~4 depth 카테고리 추출. 기존 카테고리 목록을 컨텍스트로 전달.
|
||||||
|
*/
|
||||||
|
private List<String> extractCategoryPaths(String userId, String text, String modelId) {
|
||||||
|
if (!genAiService.isConfigured()) {
|
||||||
|
log.info("OCI GenAI not configured, skipping categorization");
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 기존 카테고리 목록
|
||||||
|
List<Map<String, Object>> existing = categoryRepository.findAllByUser(userId);
|
||||||
|
String existingPaths = existing.stream()
|
||||||
|
.map(c -> (String) c.get("FULL_PATH"))
|
||||||
|
.collect(Collectors.joining("\n"));
|
||||||
|
|
||||||
|
String preview = text.length() > TEXT_PREVIEW_LENGTH
|
||||||
|
? text.substring(0, TEXT_PREVIEW_LENGTH) : text;
|
||||||
|
|
||||||
|
String systemMsg = "You are a categorization assistant. " +
|
||||||
|
"Analyze the content and assign 1-3 hierarchical categories. " +
|
||||||
|
"Each category should be 2-4 levels deep, separated by '/'. " +
|
||||||
|
"Examples: '건강/운동/웨이트트레이닝', 'IT/AI/LLM', '요리/한식'. " +
|
||||||
|
"Respond with ONLY a JSON array of category path strings. " +
|
||||||
|
"No explanation, no markdown formatting. Just the JSON array.";
|
||||||
|
|
||||||
|
StringBuilder userMsg = new StringBuilder();
|
||||||
|
if (!existingPaths.isBlank()) {
|
||||||
|
userMsg.append("Existing categories (reuse these when appropriate):\n");
|
||||||
|
userMsg.append(existingPaths);
|
||||||
|
userMsg.append("\n\n");
|
||||||
|
}
|
||||||
|
userMsg.append("Categorize the following content:\n\n");
|
||||||
|
userMsg.append(preview);
|
||||||
|
|
||||||
|
String response = genAiService.chat(systemMsg, userMsg.toString(), modelId).strip();
|
||||||
|
// JSON 배열 파싱 — 마크다운 코드블록 제거
|
||||||
|
response = response.replaceAll("```json\\s*", "").replaceAll("```\\s*", "").strip();
|
||||||
|
|
||||||
|
List<String> paths = objectMapper.readValue(response, new TypeReference<>() {});
|
||||||
|
// 유효성 검증: 슬래시가 있고 빈 값 아닌 것만
|
||||||
|
return paths.stream()
|
||||||
|
.filter(p -> p != null && !p.isBlank() && p.contains("/"))
|
||||||
|
.map(String::strip)
|
||||||
|
.toList();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("LLM categorization failed", e);
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 청크들을 임베딩하여 knowledge_chunk_embeddings 테이블에 저장.
|
||||||
|
*/
|
||||||
|
private void embedChunks(String knowledgeItemId, List<String> chunkContents) {
|
||||||
|
if (!genAiService.isConfigured()) {
|
||||||
|
log.info("OCI GenAI not configured, skipping embedding");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// chunk ID 목록 조회
|
||||||
|
List<Map<String, Object>> storedChunks = chunkRepository.findByKnowledgeItemId(knowledgeItemId);
|
||||||
|
if (storedChunks.size() != chunkContents.size()) {
|
||||||
|
log.warn("Chunk count mismatch: stored={}, content={}", storedChunks.size(), chunkContents.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
List<float[]> embeddings = genAiService.embedTexts(chunkContents, "SEARCH_DOCUMENT");
|
||||||
|
|
||||||
|
String embedModelId = "cohere.embed-v4.0";
|
||||||
|
for (int i = 0; i < Math.min(storedChunks.size(), embeddings.size()); i++) {
|
||||||
|
String chunkId = (String) storedChunks.get(i).get("ID");
|
||||||
|
embeddingRepository.upsertEmbedding(chunkId, embedModelId, embeddings.get(i));
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Item {} embedded {} chunks", knowledgeItemId, embeddings.size());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Embedding failed for item {}, continuing pipeline", knowledgeItemId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void categorize(String knowledgeItemId, String userId, String text, String modelId) {
|
||||||
|
try {
|
||||||
|
List<String> categoryPaths = extractCategoryPaths(userId, text, modelId);
|
||||||
|
for (String path : categoryPaths) {
|
||||||
|
String categoryId = categoryRepository.findOrCreate(userId, path);
|
||||||
|
categoryRepository.linkCategory(knowledgeItemId, categoryId);
|
||||||
|
}
|
||||||
|
log.info("Item {} categorized with {} categories: {}", knowledgeItemId, categoryPaths.size(), categoryPaths);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Categorization failed for item {}, continuing pipeline", knowledgeItemId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 수동 구조화 요청 (비동기). 프론트엔드 버튼에서 호출.
|
||||||
|
*/
|
||||||
|
@Async
|
||||||
|
public void runStructuring(String knowledgeItemId, String text, String modelId, String englishLevel) {
|
||||||
|
try {
|
||||||
|
String structured = structureContent(text, modelId, knowledgeItemId, englishLevel);
|
||||||
|
if (structured != null && !structured.isBlank()) {
|
||||||
|
knowledgeRepository.updateStructuredContent(knowledgeItemId, structured);
|
||||||
|
}
|
||||||
|
knowledgeRepository.updateStatus(knowledgeItemId, "READY");
|
||||||
|
log.info("Manual structuring complete for item {}", knowledgeItemId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Manual structuring failed for item {}", knowledgeItemId, e);
|
||||||
|
knowledgeRepository.updateStatus(knowledgeItemId, "READY");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Async
|
||||||
|
public void runPipeline(String knowledgeItemId, String modelId) {
|
||||||
|
try {
|
||||||
|
Map<String, Object> item = knowledgeRepository.findByIdInternal(knowledgeItemId);
|
||||||
|
if (item == null) {
|
||||||
|
log.error("Knowledge item not found: {}", knowledgeItemId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String type = (String) item.get("TYPE");
|
||||||
|
String sourceUrl = (String) item.get("SOURCE_URL");
|
||||||
|
Object rawTextObj = item.get("RAW_TEXT");
|
||||||
|
String rawText = rawTextObj != null ? rawTextObj.toString() : null;
|
||||||
|
|
||||||
|
// Step 1: Extract text
|
||||||
|
knowledgeRepository.updateStatus(knowledgeItemId, "EXTRACTING");
|
||||||
|
String extractedText;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case "WEB" -> {
|
||||||
|
extractedText = webCrawlerService.crawl(sourceUrl);
|
||||||
|
// WEB은 페이지 제목을 우선 시도
|
||||||
|
String title = (String) item.get("TITLE");
|
||||||
|
if (title == null || title.isBlank()) {
|
||||||
|
try {
|
||||||
|
String pageTitle = webCrawlerService.extractTitle(sourceUrl);
|
||||||
|
if (pageTitle != null && !pageTitle.isBlank()) {
|
||||||
|
knowledgeRepository.updateTitle(knowledgeItemId, pageTitle);
|
||||||
|
item.put("TITLE", pageTitle);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to extract title from {}", sourceUrl, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "YOUTUBE" -> {
|
||||||
|
if (rawText == null || rawText.isBlank()) {
|
||||||
|
knowledgeRepository.updateStatus(knowledgeItemId, "FAILED");
|
||||||
|
log.error("YouTube item has no raw text: {}", knowledgeItemId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
extractedText = rawText;
|
||||||
|
}
|
||||||
|
case "TEXT" -> {
|
||||||
|
if (rawText == null || rawText.isBlank()) {
|
||||||
|
knowledgeRepository.updateStatus(knowledgeItemId, "FAILED");
|
||||||
|
log.error("Text item has no raw text: {}", knowledgeItemId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
extractedText = rawText;
|
||||||
|
}
|
||||||
|
default -> {
|
||||||
|
knowledgeRepository.updateStatus(knowledgeItemId, "FAILED");
|
||||||
|
log.error("Unknown type: {}", type);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-generate title if not set
|
||||||
|
String currentTitle = (String) item.get("TITLE");
|
||||||
|
if (currentTitle == null || currentTitle.isBlank()) {
|
||||||
|
String autoTitle = generateTitle(extractedText, modelId);
|
||||||
|
knowledgeRepository.updateTitle(knowledgeItemId, autoTitle);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Structure content (1000자 이상일 때만)
|
||||||
|
knowledgeRepository.updateStatus(knowledgeItemId, "STRUCTURING");
|
||||||
|
try {
|
||||||
|
// 사용자의 영어 수준 가져오기
|
||||||
|
String userId = (String) item.get("USER_ID");
|
||||||
|
String englishLevel = "B2";
|
||||||
|
try {
|
||||||
|
Map<String, Object> user = userRepository.findById(userId);
|
||||||
|
if (user != null && user.get("ENGLISH_LEVEL") != null) {
|
||||||
|
englishLevel = (String) user.get("ENGLISH_LEVEL");
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
|
||||||
|
String structured = structureContent(extractedText, modelId, knowledgeItemId, englishLevel);
|
||||||
|
if (structured != null && !structured.isBlank()) {
|
||||||
|
knowledgeRepository.updateStructuredContent(knowledgeItemId, structured);
|
||||||
|
log.info("Item {} structured: {} chars", knowledgeItemId, structured.length());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Structuring failed for item {}, continuing pipeline", knowledgeItemId, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Chunk
|
||||||
|
knowledgeRepository.updateStatus(knowledgeItemId, "CHUNKING");
|
||||||
|
List<String> chunks = chunkingService.chunk(extractedText);
|
||||||
|
log.info("Item {} chunked into {} pieces", knowledgeItemId, chunks.size());
|
||||||
|
|
||||||
|
for (int i = 0; i < chunks.size(); i++) {
|
||||||
|
String chunkContent = chunks.get(i);
|
||||||
|
int tokenCount = chunkingService.estimateTokenCount(chunkContent);
|
||||||
|
chunkRepository.insertChunk(knowledgeItemId, i, chunkContent, tokenCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Categorize
|
||||||
|
knowledgeRepository.updateStatus(knowledgeItemId, "CATEGORIZING");
|
||||||
|
categorize(knowledgeItemId, (String) item.get("USER_ID"), extractedText, modelId);
|
||||||
|
|
||||||
|
// Step 5: Embedding
|
||||||
|
knowledgeRepository.updateStatus(knowledgeItemId, "EMBEDDING");
|
||||||
|
embedChunks(knowledgeItemId, chunks);
|
||||||
|
|
||||||
|
// Done
|
||||||
|
knowledgeRepository.updateStatus(knowledgeItemId, "READY");
|
||||||
|
log.info("Pipeline complete for item {}: {} chunks stored", knowledgeItemId, chunks.size());
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Pipeline failed for item {}", knowledgeItemId, e);
|
||||||
|
try {
|
||||||
|
knowledgeRepository.updateStatus(knowledgeItemId, "FAILED");
|
||||||
|
} catch (Exception ex) {
|
||||||
|
log.error("Failed to update status to FAILED", ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
package com.sundol.service;
|
package com.sundol.service;
|
||||||
|
|
||||||
import com.sundol.dto.IngestRequest;
|
import com.sundol.dto.IngestRequest;
|
||||||
|
import com.sundol.exception.AppException;
|
||||||
|
import com.sundol.repository.CategoryRepository;
|
||||||
|
import com.sundol.repository.KnowledgeChunkRepository;
|
||||||
import com.sundol.repository.KnowledgeRepository;
|
import com.sundol.repository.KnowledgeRepository;
|
||||||
|
import com.sundol.repository.UserRepository;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.core.scheduler.Schedulers;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -12,38 +18,132 @@ import java.util.Map;
|
|||||||
public class KnowledgeService {
|
public class KnowledgeService {
|
||||||
|
|
||||||
private final KnowledgeRepository knowledgeRepository;
|
private final KnowledgeRepository knowledgeRepository;
|
||||||
|
private final KnowledgeChunkRepository chunkRepository;
|
||||||
|
private final CategoryRepository categoryRepository;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final IngestPipelineService pipelineService;
|
||||||
|
|
||||||
public KnowledgeService(KnowledgeRepository knowledgeRepository) {
|
public KnowledgeService(
|
||||||
|
KnowledgeRepository knowledgeRepository,
|
||||||
|
KnowledgeChunkRepository chunkRepository,
|
||||||
|
CategoryRepository categoryRepository,
|
||||||
|
UserRepository userRepository,
|
||||||
|
IngestPipelineService pipelineService) {
|
||||||
this.knowledgeRepository = knowledgeRepository;
|
this.knowledgeRepository = knowledgeRepository;
|
||||||
|
this.chunkRepository = chunkRepository;
|
||||||
|
this.categoryRepository = categoryRepository;
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
this.pipelineService = pipelineService;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveEnglishLevel(String userId, String requestLevel) {
|
||||||
|
if (requestLevel != null && requestLevel.matches("^(A1|A2|B1|B2|C1|C2)$")) {
|
||||||
|
return requestLevel;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Map<String, Object> user = userRepository.findById(userId);
|
||||||
|
if (user != null && user.get("ENGLISH_LEVEL") != null) {
|
||||||
|
return (String) user.get("ENGLISH_LEVEL");
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
return "B2";
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<List<Map<String, Object>>> list(String userId, String type, String status, String tag, String search) {
|
public Mono<List<Map<String, Object>>> list(String userId, String type, String status, String tag, String search) {
|
||||||
// TODO: Query knowledge items with filters
|
return Mono.fromCallable(() -> knowledgeRepository.list(userId, type, status, search))
|
||||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
.subscribeOn(Schedulers.boundedElastic());
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<Map<String, Object>> ingest(String userId, IngestRequest request) {
|
public Mono<Map<String, Object>> ingest(String userId, IngestRequest request) {
|
||||||
// TODO: Create knowledge item, trigger async ingest pipeline
|
return Mono.fromCallable(() -> {
|
||||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
String id = knowledgeRepository.insert(
|
||||||
|
userId, request.type(), request.title(), request.url(), request.rawText()
|
||||||
|
);
|
||||||
|
// Trigger async pipeline
|
||||||
|
pipelineService.runPipeline(id, request.modelId());
|
||||||
|
|
||||||
|
return Map.of(
|
||||||
|
"id", (Object) id,
|
||||||
|
"status", "PENDING"
|
||||||
|
);
|
||||||
|
}).subscribeOn(Schedulers.boundedElastic());
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<Map<String, Object>> getById(String userId, String id) {
|
public Mono<Map<String, Object>> getById(String userId, String id) {
|
||||||
// TODO: Get knowledge item by ID
|
return Mono.fromCallable(() -> {
|
||||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
Map<String, Object> item = knowledgeRepository.findById(userId, id);
|
||||||
|
if (item == null) {
|
||||||
|
throw new AppException(HttpStatus.NOT_FOUND, "Knowledge item not found");
|
||||||
|
}
|
||||||
|
item.put("CATEGORIES", categoryRepository.findByKnowledgeItemId(id));
|
||||||
|
return item;
|
||||||
|
}).subscribeOn(Schedulers.boundedElastic());
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<Map<String, Object>> update(String userId, String id, Map<String, Object> updates) {
|
public Mono<Map<String, Object>> update(String userId, String id, Map<String, Object> updates) {
|
||||||
// TODO: Update knowledge item fields
|
return Mono.fromCallable(() -> {
|
||||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
Map<String, Object> item = knowledgeRepository.findById(userId, id);
|
||||||
|
if (item == null) {
|
||||||
|
throw new AppException(HttpStatus.NOT_FOUND, "Knowledge item not found");
|
||||||
|
}
|
||||||
|
if (updates.containsKey("title")) {
|
||||||
|
knowledgeRepository.updateTitle(id, (String) updates.get("title"));
|
||||||
|
}
|
||||||
|
return knowledgeRepository.findById(userId, id);
|
||||||
|
}).subscribeOn(Schedulers.boundedElastic());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Mono<Map<String, Object>> structureContent(String userId, String id, String modelId, String englishLevel) {
|
||||||
|
return Mono.fromCallable(() -> {
|
||||||
|
Map<String, Object> item = knowledgeRepository.findById(userId, id);
|
||||||
|
if (item == null) {
|
||||||
|
throw new AppException(HttpStatus.NOT_FOUND, "Knowledge item not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// raw_text 또는 청크에서 원본 텍스트 가져오기
|
||||||
|
Object rawTextObj = item.get("RAW_TEXT");
|
||||||
|
String text = rawTextObj != null ? rawTextObj.toString() : null;
|
||||||
|
|
||||||
|
if (text == null || text.isBlank()) {
|
||||||
|
// WEB 타입은 raw_text가 없을 수 있으므로 청크에서 조합
|
||||||
|
var chunks = chunkRepository.findByKnowledgeItemId(id);
|
||||||
|
if (!chunks.isEmpty()) {
|
||||||
|
text = chunks.stream()
|
||||||
|
.map(c -> c.get("CONTENT").toString())
|
||||||
|
.collect(java.util.stream.Collectors.joining("\n\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (text == null || text.isBlank()) {
|
||||||
|
throw new AppException(HttpStatus.BAD_REQUEST, "No content to structure");
|
||||||
|
}
|
||||||
|
|
||||||
|
// STRUCTURING 상태로 변경 (프론트엔드 폴링에서 진행 중 표시)
|
||||||
|
knowledgeRepository.updateStatus(id, "STRUCTURING");
|
||||||
|
|
||||||
|
// 영어 수준 결정 (요청 값 > 사용자 프로필 > B2 기본)
|
||||||
|
String level = resolveEnglishLevel(userId, englishLevel);
|
||||||
|
|
||||||
|
// 비동기로 구조화 실행 (중간 결과는 pipelineService가 DB에 직접 저장)
|
||||||
|
final String finalText = text;
|
||||||
|
pipelineService.runStructuring(id, finalText, modelId, level);
|
||||||
|
|
||||||
|
return knowledgeRepository.findById(userId, id);
|
||||||
|
}).subscribeOn(Schedulers.boundedElastic());
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<Void> delete(String userId, String id) {
|
public Mono<Void> delete(String userId, String id) {
|
||||||
// TODO: Delete knowledge item and all chunks
|
return Mono.fromRunnable(() -> knowledgeRepository.delete(userId, id))
|
||||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
.subscribeOn(Schedulers.boundedElastic()).then();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<List<Map<String, Object>>> getChunks(String userId, String id) {
|
public Mono<List<Map<String, Object>>> getChunks(String userId, String id) {
|
||||||
// TODO: List chunks for knowledge item
|
return Mono.fromCallable(() -> {
|
||||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
Map<String, Object> item = knowledgeRepository.findById(userId, id);
|
||||||
|
if (item == null) {
|
||||||
|
throw new AppException(HttpStatus.NOT_FOUND, "Knowledge item not found");
|
||||||
|
}
|
||||||
|
return chunkRepository.findByKnowledgeItemId(id);
|
||||||
|
}).subscribeOn(Schedulers.boundedElastic());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package com.sundol.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.net.http.HttpClient;
|
||||||
|
import java.net.http.HttpRequest;
|
||||||
|
import java.net.http.HttpResponse;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class OciGenAiService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(OciGenAiService.class);
|
||||||
|
|
||||||
|
private final String apiKey;
|
||||||
|
private final String compartment;
|
||||||
|
private final String defaultModel;
|
||||||
|
private final String baseUrl;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final HttpClient httpClient;
|
||||||
|
|
||||||
|
public static final List<Map<String, String>> AVAILABLE_MODELS = List.of(
|
||||||
|
Map.of("id", "google.gemini-2.5-pro", "name", "Gemini 2.5 Pro", "vendor", "Google"),
|
||||||
|
Map.of("id", "google.gemini-2.5-flash", "name", "Gemini 2.5 Flash", "vendor", "Google"),
|
||||||
|
Map.of("id", "google.gemini-2.5-flash-lite", "name", "Gemini 2.5 Flash Lite", "vendor", "Google"),
|
||||||
|
Map.of("id", "xai.grok-4.20-reasoning", "name", "Grok 4.20 Reasoning", "vendor", "xAI"),
|
||||||
|
Map.of("id", "xai.grok-4.20-non-reasoning", "name", "Grok 4.20 Non-Reasoning", "vendor", "xAI"),
|
||||||
|
Map.of("id", "xai.grok-4-1-fast-reasoning", "name", "Grok 4-1 Fast Reasoning", "vendor", "xAI"),
|
||||||
|
Map.of("id", "xai.grok-4-1-fast-non-reasoning", "name", "Grok 4-1 Fast Non-Reasoning", "vendor", "xAI"),
|
||||||
|
Map.of("id", "xai.grok-4", "name", "Grok 4", "vendor", "xAI"),
|
||||||
|
Map.of("id", "xai.grok-3", "name", "Grok 3", "vendor", "xAI"),
|
||||||
|
Map.of("id", "xai.grok-3-mini", "name", "Grok 3 Mini", "vendor", "xAI"),
|
||||||
|
Map.of("id", "openai.gpt-oss-120b", "name", "GPT-OSS 120B", "vendor", "OpenAI"),
|
||||||
|
Map.of("id", "openai.gpt-oss-20b", "name", "GPT-OSS 20B", "vendor", "OpenAI"),
|
||||||
|
Map.of("id", "cohere.command-a-03-2025", "name", "Command A", "vendor", "Cohere"),
|
||||||
|
Map.of("id", "cohere.command-a-reasoning", "name", "Command A Reasoning", "vendor", "Cohere"),
|
||||||
|
Map.of("id", "cohere.command-r-plus-08-2024", "name", "Command R+", "vendor", "Cohere"),
|
||||||
|
Map.of("id", "meta.llama-4-maverick-17b-128e-instruct-fp8", "name", "Llama 4 Maverick 17B", "vendor", "Meta"),
|
||||||
|
Map.of("id", "meta.llama-4-scout-17b-16e-instruct", "name", "Llama 4 Scout 17B", "vendor", "Meta")
|
||||||
|
);
|
||||||
|
|
||||||
|
public OciGenAiService(
|
||||||
|
@Value("${oci.genai.api-key:}") String apiKey,
|
||||||
|
@Value("${oci.genai.compartment:}") String compartment,
|
||||||
|
@Value("${oci.genai.model:google.gemini-2.5-flash}") String defaultModel,
|
||||||
|
@Value("${oci.genai.base-url:}") String baseUrl,
|
||||||
|
ObjectMapper objectMapper) {
|
||||||
|
this.apiKey = apiKey;
|
||||||
|
this.compartment = compartment;
|
||||||
|
this.defaultModel = defaultModel;
|
||||||
|
this.baseUrl = baseUrl;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
|
this.httpClient = HttpClient.newBuilder()
|
||||||
|
.connectTimeout(Duration.ofSeconds(30))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isConfigured() {
|
||||||
|
return apiKey != null && !apiKey.isBlank()
|
||||||
|
&& compartment != null && !compartment.isBlank();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDefaultModel() {
|
||||||
|
return defaultModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OCI GenAI Chat API 호출.
|
||||||
|
*/
|
||||||
|
public String chat(String systemMessage, String userMessage, String modelId) throws Exception {
|
||||||
|
if (!isConfigured()) {
|
||||||
|
throw new IllegalStateException("OCI GenAI is not configured");
|
||||||
|
}
|
||||||
|
if (modelId == null || modelId.isBlank()) {
|
||||||
|
modelId = defaultModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> payload = Map.of(
|
||||||
|
"compartmentId", compartment,
|
||||||
|
"servingMode", Map.of(
|
||||||
|
"servingType", "ON_DEMAND",
|
||||||
|
"modelId", modelId
|
||||||
|
),
|
||||||
|
"chatRequest", Map.of(
|
||||||
|
"apiFormat", "GENERIC",
|
||||||
|
"messages", List.of(
|
||||||
|
Map.of("role", "SYSTEM", "content", List.of(Map.of("type", "TEXT", "text", systemMessage))),
|
||||||
|
Map.of("role", "USER", "content", List.of(Map.of("type", "TEXT", "text", userMessage)))
|
||||||
|
),
|
||||||
|
"maxTokens", 4096,
|
||||||
|
"temperature", 0.3
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
String body = objectMapper.writeValueAsString(payload);
|
||||||
|
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(baseUrl + "/chat"))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Authorization", "Bearer " + apiKey)
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||||
|
.timeout(Duration.ofSeconds(120))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
|
||||||
|
if (response.statusCode() != 200) {
|
||||||
|
log.error("OCI GenAI error {} (model={}): {}", response.statusCode(), modelId,
|
||||||
|
response.body().substring(0, Math.min(response.body().length(), 500)));
|
||||||
|
throw new RuntimeException("OCI GenAI returned " + response.statusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode root = objectMapper.readTree(response.body());
|
||||||
|
JsonNode choices = root.path("chatResponse").path("choices");
|
||||||
|
if (choices.isArray() && !choices.isEmpty()) {
|
||||||
|
JsonNode content = choices.get(0).path("message").path("content");
|
||||||
|
if (content.isArray() && !content.isEmpty()) {
|
||||||
|
return content.get(0).path("text").asText("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new RuntimeException("Unexpected OCI GenAI response structure");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final String EMBED_MODEL = "cohere.embed-v4.0";
|
||||||
|
private static final int EMBED_BATCH_SIZE = 96;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OCI GenAI Embed API 호출. 최대 96개씩 배치 처리.
|
||||||
|
* @param texts 임베딩할 텍스트 목록
|
||||||
|
* @param inputType SEARCH_DOCUMENT (저장 시) 또는 SEARCH_QUERY (검색 시)
|
||||||
|
* @return 각 텍스트에 대응하는 float[] 벡터 리스트
|
||||||
|
*/
|
||||||
|
public List<float[]> embedTexts(List<String> texts, String inputType) throws Exception {
|
||||||
|
if (!isConfigured()) {
|
||||||
|
throw new IllegalStateException("OCI GenAI is not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<float[]> allEmbeddings = new ArrayList<>();
|
||||||
|
|
||||||
|
for (int start = 0; start < texts.size(); start += EMBED_BATCH_SIZE) {
|
||||||
|
int end = Math.min(start + EMBED_BATCH_SIZE, texts.size());
|
||||||
|
List<String> batch = texts.subList(start, end);
|
||||||
|
|
||||||
|
Map<String, Object> payload = Map.of(
|
||||||
|
"compartmentId", compartment,
|
||||||
|
"servingMode", Map.of(
|
||||||
|
"servingType", "ON_DEMAND",
|
||||||
|
"modelId", EMBED_MODEL
|
||||||
|
),
|
||||||
|
"inputs", batch,
|
||||||
|
"inputType", inputType,
|
||||||
|
"truncate", "END"
|
||||||
|
);
|
||||||
|
|
||||||
|
String body = objectMapper.writeValueAsString(payload);
|
||||||
|
|
||||||
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
|
.uri(URI.create(baseUrl + "/embedText"))
|
||||||
|
.header("Content-Type", "application/json")
|
||||||
|
.header("Authorization", "Bearer " + apiKey)
|
||||||
|
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||||
|
.timeout(Duration.ofSeconds(120))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
|
|
||||||
|
if (response.statusCode() != 200) {
|
||||||
|
log.error("OCI GenAI embed error {}: {}", response.statusCode(),
|
||||||
|
response.body().substring(0, Math.min(response.body().length(), 500)));
|
||||||
|
throw new RuntimeException("OCI GenAI embed returned " + response.statusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
JsonNode root = objectMapper.readTree(response.body());
|
||||||
|
JsonNode embeddings = root.path("embeddings");
|
||||||
|
if (!embeddings.isArray()) {
|
||||||
|
throw new RuntimeException("Unexpected embed response: no embeddings array");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (JsonNode embNode : embeddings) {
|
||||||
|
float[] vec = new float[embNode.size()];
|
||||||
|
for (int i = 0; i < embNode.size(); i++) {
|
||||||
|
vec[i] = (float) embNode.get(i).asDouble();
|
||||||
|
}
|
||||||
|
allEmbeddings.add(vec);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("Embedded batch [{}-{}] of {} texts", start, end, texts.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
return allEmbeddings;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package com.sundol.service;
|
||||||
|
|
||||||
|
import com.microsoft.playwright.*;
|
||||||
|
import com.microsoft.playwright.options.WaitUntilState;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import jakarta.annotation.PreDestroy;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 Chrome 브라우저에 CDP(Chrome DevTools Protocol)로 연결하여 사용.
|
||||||
|
* Chrome은 --remote-debugging-port=9222로 실행되어 있어야 한다.
|
||||||
|
* VNC에서 사용자가 직접 로그인한 세션을 그대로 사용하므로 봇 판정을 우회할 수 있다.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class PlaywrightBrowserService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(PlaywrightBrowserService.class);
|
||||||
|
private static final String CDP_URL = "http://localhost:9222";
|
||||||
|
|
||||||
|
private Playwright playwright;
|
||||||
|
private Browser browser;
|
||||||
|
|
||||||
|
@PostConstruct
|
||||||
|
public void init() {
|
||||||
|
try {
|
||||||
|
playwright = Playwright.create();
|
||||||
|
connectToChrome();
|
||||||
|
log.info("사용자 Chrome에 CDP 연결 완료");
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Chrome CDP 연결 실패: {}. Chrome이 --remote-debugging-port=9222로 실행 중인지 확인하세요.", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void connectToChrome() {
|
||||||
|
browser = playwright.chromium().connectOverCDP(CDP_URL);
|
||||||
|
log.info("CDP 연결: {} contexts, {} pages",
|
||||||
|
browser.contexts().size(),
|
||||||
|
browser.contexts().stream().mapToInt(c -> c.pages().size()).sum());
|
||||||
|
}
|
||||||
|
|
||||||
|
@PreDestroy
|
||||||
|
public void destroy() {
|
||||||
|
try {
|
||||||
|
// CDP 연결만 끊음 (Chrome 자체는 종료하지 않음)
|
||||||
|
if (browser != null) browser.close();
|
||||||
|
if (playwright != null) playwright.close();
|
||||||
|
log.info("Chrome CDP 연결 해제 완료");
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("CDP 연결 해제 중 오류: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 Chrome의 기본 컨텍스트를 가져온다.
|
||||||
|
*/
|
||||||
|
private BrowserContext getDefaultContext() throws IOException {
|
||||||
|
ensureBrowserAlive();
|
||||||
|
List<BrowserContext> contexts = browser.contexts();
|
||||||
|
if (contexts.isEmpty()) {
|
||||||
|
throw new IOException("Chrome에 활성 컨텍스트가 없습니다.");
|
||||||
|
}
|
||||||
|
return contexts.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 새 탭을 열고 URL로 이동한다.
|
||||||
|
*/
|
||||||
|
public Page openPage(String url, int timeoutMs, WaitUntilState waitUntil) throws IOException {
|
||||||
|
BrowserContext ctx = getDefaultContext();
|
||||||
|
Page page = ctx.newPage();
|
||||||
|
try {
|
||||||
|
page.navigate(url, new Page.NavigateOptions()
|
||||||
|
.setTimeout(timeoutMs)
|
||||||
|
.setWaitUntil(waitUntil));
|
||||||
|
return page;
|
||||||
|
} catch (Exception e) {
|
||||||
|
page.close();
|
||||||
|
throw new IOException("페이지 로드 실패 (" + url + "): " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Page openPage(String url, int timeoutMs) throws IOException {
|
||||||
|
return openPage(url, timeoutMs, WaitUntilState.NETWORKIDLE);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Page openPage(String url) throws IOException {
|
||||||
|
return openPage(url, 30_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 브라우저 페이지 내에서 JavaScript fetch로 URL 내용을 가져온다.
|
||||||
|
*/
|
||||||
|
public String fetchInPage(Page page, String url) throws IOException {
|
||||||
|
try {
|
||||||
|
Object result = page.evaluate("async (url) => {" +
|
||||||
|
" const res = await fetch(url, { credentials: 'include' });" +
|
||||||
|
" if (!res.ok) throw new Error('HTTP ' + res.status);" +
|
||||||
|
" return await res.text();" +
|
||||||
|
"}", url);
|
||||||
|
return result != null ? result.toString() : "";
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IOException("브라우저 내 fetch 실패 (" + url + "): " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chrome 연결이 끊겼으면 재연결한다.
|
||||||
|
*/
|
||||||
|
private synchronized void ensureBrowserAlive() throws IOException {
|
||||||
|
if (browser != null && browser.isConnected()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log.warn("Chrome CDP 연결이 끊겼습니다. 재연결합니다.");
|
||||||
|
try {
|
||||||
|
if (browser != null) {
|
||||||
|
try { browser.close(); } catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
connectToChrome();
|
||||||
|
log.info("Chrome CDP 재연결 완료");
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IOException("Chrome CDP 재연결 실패. Chrome이 실행 중인지 확인하세요: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
package com.sundol.service;
|
package com.sundol.service;
|
||||||
|
|
||||||
import com.sundol.repository.KnowledgeChunkRepository;
|
import com.sundol.repository.ChunkEmbeddingRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.core.scheduler.Schedulers;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -10,14 +13,24 @@ import java.util.Map;
|
|||||||
@Service
|
@Service
|
||||||
public class SearchService {
|
public class SearchService {
|
||||||
|
|
||||||
private final KnowledgeChunkRepository chunkRepository;
|
private static final Logger log = LoggerFactory.getLogger(SearchService.class);
|
||||||
|
|
||||||
public SearchService(KnowledgeChunkRepository chunkRepository) {
|
private final ChunkEmbeddingRepository embeddingRepository;
|
||||||
this.chunkRepository = chunkRepository;
|
private final OciGenAiService genAiService;
|
||||||
|
|
||||||
|
public SearchService(ChunkEmbeddingRepository embeddingRepository, OciGenAiService genAiService) {
|
||||||
|
this.embeddingRepository = embeddingRepository;
|
||||||
|
this.genAiService = genAiService;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 시맨틱 검색: 쿼리를 임베딩 → VECTOR_DISTANCE로 유사 청크 검색
|
||||||
|
*/
|
||||||
public Mono<List<Map<String, Object>>> search(String userId, String query, int topK) {
|
public Mono<List<Map<String, Object>>> search(String userId, String query, int topK) {
|
||||||
// TODO: Embed query via OCI GenAI, VECTOR_DISTANCE search
|
return Mono.fromCallable(() -> {
|
||||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
List<float[]> embeddings = genAiService.embedTexts(List.of(query), "SEARCH_QUERY");
|
||||||
|
float[] queryVector = embeddings.get(0);
|
||||||
|
return embeddingRepository.searchSimilar(userId, queryVector, topK);
|
||||||
|
}).subscribeOn(Schedulers.boundedElastic());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,17 @@
|
|||||||
package com.sundol.service;
|
package com.sundol.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.sundol.exception.AppException;
|
||||||
|
import com.sundol.repository.KnowledgeChunkRepository;
|
||||||
|
import com.sundol.repository.KnowledgeRepository;
|
||||||
import com.sundol.repository.StudyCardRepository;
|
import com.sundol.repository.StudyCardRepository;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.core.scheduler.Schedulers;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -10,29 +19,137 @@ import java.util.Map;
|
|||||||
@Service
|
@Service
|
||||||
public class StudyCardService {
|
public class StudyCardService {
|
||||||
|
|
||||||
private final StudyCardRepository studyCardRepository;
|
private static final Logger log = LoggerFactory.getLogger(StudyCardService.class);
|
||||||
|
|
||||||
public StudyCardService(StudyCardRepository studyCardRepository) {
|
private final StudyCardRepository studyCardRepository;
|
||||||
|
private final KnowledgeRepository knowledgeRepository;
|
||||||
|
private final KnowledgeChunkRepository chunkRepository;
|
||||||
|
private final OciGenAiService genAiService;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
public StudyCardService(
|
||||||
|
StudyCardRepository studyCardRepository,
|
||||||
|
KnowledgeRepository knowledgeRepository,
|
||||||
|
KnowledgeChunkRepository chunkRepository,
|
||||||
|
OciGenAiService genAiService,
|
||||||
|
ObjectMapper objectMapper) {
|
||||||
this.studyCardRepository = studyCardRepository;
|
this.studyCardRepository = studyCardRepository;
|
||||||
|
this.knowledgeRepository = knowledgeRepository;
|
||||||
|
this.chunkRepository = chunkRepository;
|
||||||
|
this.genAiService = genAiService;
|
||||||
|
this.objectMapper = objectMapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<List<Map<String, Object>>> getDueCards(String userId) {
|
public Mono<List<Map<String, Object>>> getDueCards(String userId) {
|
||||||
// TODO: Get cards due for review today
|
return Mono.fromCallable(() -> studyCardRepository.getDueCards(userId))
|
||||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
.subscribeOn(Schedulers.boundedElastic());
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<List<Map<String, Object>>> getByKnowledgeItem(String userId, String knowledgeItemId) {
|
public Mono<List<Map<String, Object>>> getByKnowledgeItem(String userId, String knowledgeItemId) {
|
||||||
// TODO: Get cards for a specific knowledge item
|
return Mono.fromCallable(() -> studyCardRepository.getByKnowledgeItem(userId, knowledgeItemId))
|
||||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
.subscribeOn(Schedulers.boundedElastic());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Knowledge item의 청크들을 기반으로 LLM이 Q&A 카드를 자동 생성.
|
||||||
|
*/
|
||||||
public Mono<Map<String, Object>> generate(String userId, String knowledgeItemId) {
|
public Mono<Map<String, Object>> generate(String userId, String knowledgeItemId) {
|
||||||
// TODO: Trigger AI card generation from knowledge item
|
return Mono.fromCallable(() -> {
|
||||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
Map<String, Object> item = knowledgeRepository.findById(userId, knowledgeItemId);
|
||||||
|
if (item == null) {
|
||||||
|
throw new AppException(HttpStatus.NOT_FOUND, "Knowledge item not found");
|
||||||
|
}
|
||||||
|
if (!"READY".equals(item.get("STATUS"))) {
|
||||||
|
throw new AppException(HttpStatus.BAD_REQUEST, "Knowledge item is not ready yet");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Map<String, Object>> chunks = chunkRepository.findByKnowledgeItemId(knowledgeItemId);
|
||||||
|
if (chunks.isEmpty()) {
|
||||||
|
throw new AppException(HttpStatus.BAD_REQUEST, "No chunks available");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 청크 내용을 합쳐서 LLM에 전달 (최대 4000자)
|
||||||
|
StringBuilder content = new StringBuilder();
|
||||||
|
for (Map<String, Object> chunk : chunks) {
|
||||||
|
Object c = chunk.get("CONTENT");
|
||||||
|
if (c != null) {
|
||||||
|
content.append(c.toString()).append("\n\n");
|
||||||
|
}
|
||||||
|
if (content.length() > 4000) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
String systemMsg =
|
||||||
|
"You are a study card generator. Create flashcards from the given content. " +
|
||||||
|
"Each card has a 'front' (question) and 'back' (answer). " +
|
||||||
|
"Generate 3-5 cards that test key concepts. " +
|
||||||
|
"Use the same language as the content. " +
|
||||||
|
"Respond with ONLY a JSON array: [{\"front\":\"question\",\"back\":\"answer\"},...]. " +
|
||||||
|
"No markdown, no explanation.";
|
||||||
|
String userMsg = "Generate study cards from this content:\n\n" + content;
|
||||||
|
|
||||||
|
String response = genAiService.chat(systemMsg, userMsg, null);
|
||||||
|
response = response.replaceAll("```json\\s*", "").replaceAll("```\\s*", "").strip();
|
||||||
|
|
||||||
|
List<Map<String, String>> cards = objectMapper.readValue(response, new TypeReference<>() {});
|
||||||
|
|
||||||
|
int created = 0;
|
||||||
|
for (Map<String, String> card : cards) {
|
||||||
|
String front = card.get("front");
|
||||||
|
String back = card.get("back");
|
||||||
|
if (front != null && back != null && !front.isBlank() && !back.isBlank()) {
|
||||||
|
studyCardRepository.insert(userId, knowledgeItemId, front, back);
|
||||||
|
created++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Generated {} study cards for knowledge item {}", created, knowledgeItemId);
|
||||||
|
return Map.of("generated", (Object) created, "knowledgeItemId", knowledgeItemId);
|
||||||
|
}).subscribeOn(Schedulers.boundedElastic());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SM-2 알고리즘으로 카드 복습 결과 처리.
|
||||||
|
* rating: 0(완전 모름) ~ 5(완벽)
|
||||||
|
*/
|
||||||
public Mono<Map<String, Object>> review(String userId, String id, int rating) {
|
public Mono<Map<String, Object>> review(String userId, String id, int rating) {
|
||||||
// TODO: Apply SM-2 algorithm and update card
|
return Mono.fromCallable(() -> {
|
||||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
Map<String, Object> card = studyCardRepository.findById(userId, id);
|
||||||
|
if (card == null) {
|
||||||
|
throw new AppException(HttpStatus.NOT_FOUND, "Study card not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
Number efNum = (Number) card.get("EASE_FACTOR");
|
||||||
|
Number ivNum = (Number) card.get("INTERVAL_DAYS");
|
||||||
|
Number repNum = (Number) card.get("REPETITIONS");
|
||||||
|
|
||||||
|
double easeFactor = efNum != null ? efNum.doubleValue() : 2.5;
|
||||||
|
int intervalDays = ivNum != null ? ivNum.intValue() : 0;
|
||||||
|
int repetitions = repNum != null ? repNum.intValue() : 0;
|
||||||
|
|
||||||
|
// SM-2 알고리즘
|
||||||
|
if (rating < 3) {
|
||||||
|
// 실패: 처음부터 다시
|
||||||
|
repetitions = 0;
|
||||||
|
intervalDays = 1;
|
||||||
|
} else {
|
||||||
|
// 성공
|
||||||
|
repetitions++;
|
||||||
|
if (repetitions == 1) {
|
||||||
|
intervalDays = 1;
|
||||||
|
} else if (repetitions == 2) {
|
||||||
|
intervalDays = 6;
|
||||||
|
} else {
|
||||||
|
intervalDays = (int) Math.round(intervalDays * easeFactor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ease Factor 갱신
|
||||||
|
easeFactor = easeFactor + (0.1 - (5 - rating) * (0.08 + (5 - rating) * 0.02));
|
||||||
|
if (easeFactor < 1.3) easeFactor = 1.3;
|
||||||
|
|
||||||
|
studyCardRepository.updateSm2(id, easeFactor, intervalDays, repetitions, intervalDays);
|
||||||
|
|
||||||
|
return studyCardRepository.findById(userId, id);
|
||||||
|
}).subscribeOn(Schedulers.boundedElastic());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package com.sundol.service;
|
package com.sundol.service;
|
||||||
|
|
||||||
import com.sundol.dto.TagRequest;
|
import com.sundol.dto.TagRequest;
|
||||||
|
import com.sundol.exception.AppException;
|
||||||
import com.sundol.repository.TagRepository;
|
import com.sundol.repository.TagRepository;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.core.scheduler.Schedulers;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -18,22 +21,41 @@ public class TagService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Mono<List<Map<String, Object>>> list(String userId) {
|
public Mono<List<Map<String, Object>>> list(String userId) {
|
||||||
// TODO: List tags for user
|
return Mono.fromCallable(() -> tagRepository.list(userId))
|
||||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
.subscribeOn(Schedulers.boundedElastic());
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<Map<String, Object>> create(String userId, TagRequest request) {
|
public Mono<Map<String, Object>> create(String userId, TagRequest request) {
|
||||||
// TODO: Create tag
|
return Mono.fromCallable(() -> {
|
||||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
String color = request.color() != null ? request.color() : "#6366f1";
|
||||||
|
String id = tagRepository.insert(userId, request.name(), color);
|
||||||
|
return tagRepository.findById(userId, id);
|
||||||
|
}).subscribeOn(Schedulers.boundedElastic());
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<Map<String, Object>> update(String userId, String id, TagRequest request) {
|
public Mono<Map<String, Object>> update(String userId, String id, TagRequest request) {
|
||||||
// TODO: Update tag
|
return Mono.fromCallable(() -> {
|
||||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
Map<String, Object> tag = tagRepository.findById(userId, id);
|
||||||
|
if (tag == null) {
|
||||||
|
throw new AppException(HttpStatus.NOT_FOUND, "Tag not found");
|
||||||
|
}
|
||||||
|
if (request.name() != null) {
|
||||||
|
tagRepository.updateName(id, request.name());
|
||||||
|
}
|
||||||
|
if (request.color() != null) {
|
||||||
|
tagRepository.updateColor(id, request.color());
|
||||||
|
}
|
||||||
|
return tagRepository.findById(userId, id);
|
||||||
|
}).subscribeOn(Schedulers.boundedElastic());
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<Void> delete(String userId, String id) {
|
public Mono<Void> delete(String userId, String id) {
|
||||||
// TODO: Delete tag and remove from items
|
return Mono.fromRunnable(() -> {
|
||||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
Map<String, Object> tag = tagRepository.findById(userId, id);
|
||||||
|
if (tag == null) {
|
||||||
|
throw new AppException(HttpStatus.NOT_FOUND, "Tag not found");
|
||||||
|
}
|
||||||
|
tagRepository.delete(userId, id);
|
||||||
|
}).subscribeOn(Schedulers.boundedElastic()).then();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package com.sundol.service;
|
package com.sundol.service;
|
||||||
|
|
||||||
import com.sundol.dto.TodoRequest;
|
import com.sundol.dto.TodoRequest;
|
||||||
|
import com.sundol.exception.AppException;
|
||||||
import com.sundol.repository.TodoRepository;
|
import com.sundol.repository.TodoRepository;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.core.scheduler.Schedulers;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -18,27 +21,72 @@ public class TodoService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Mono<List<Map<String, Object>>> list(String userId, String status, String priority, String dueDate) {
|
public Mono<List<Map<String, Object>>> list(String userId, String status, String priority, String dueDate) {
|
||||||
// TODO: List todos with filters
|
return Mono.fromCallable(() -> {
|
||||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
List<Map<String, Object>> todos = todoRepository.list(userId, status, priority, dueDate);
|
||||||
|
// 각 todo에 서브태스크 개수 포함
|
||||||
|
for (Map<String, Object> todo : todos) {
|
||||||
|
String todoId = (String) todo.get("ID");
|
||||||
|
List<Map<String, Object>> subtasks = todoRepository.findSubtasks(userId, todoId);
|
||||||
|
todo.put("SUBTASK_COUNT", subtasks.size());
|
||||||
|
long doneCount = subtasks.stream()
|
||||||
|
.filter(s -> "DONE".equals(s.get("STATUS")))
|
||||||
|
.count();
|
||||||
|
todo.put("SUBTASK_DONE_COUNT", doneCount);
|
||||||
|
}
|
||||||
|
return todos;
|
||||||
|
}).subscribeOn(Schedulers.boundedElastic());
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<Map<String, Object>> create(String userId, TodoRequest request) {
|
public Mono<Map<String, Object>> create(String userId, TodoRequest request) {
|
||||||
// TODO: Create todo
|
return Mono.fromCallable(() -> {
|
||||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
String priority = request.priority() != null ? request.priority() : "MEDIUM";
|
||||||
|
String id = todoRepository.insert(
|
||||||
|
userId, request.title(), request.description(),
|
||||||
|
priority, request.dueDate(), request.parentId()
|
||||||
|
);
|
||||||
|
return todoRepository.findById(userId, id);
|
||||||
|
}).subscribeOn(Schedulers.boundedElastic());
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<Map<String, Object>> update(String userId, String id, Map<String, Object> updates) {
|
public Mono<Map<String, Object>> update(String userId, String id, Map<String, Object> updates) {
|
||||||
// TODO: Update todo fields
|
return Mono.fromCallable(() -> {
|
||||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
Map<String, Object> todo = todoRepository.findById(userId, id);
|
||||||
|
if (todo == null) {
|
||||||
|
throw new AppException(HttpStatus.NOT_FOUND, "Todo not found");
|
||||||
|
}
|
||||||
|
if (updates.containsKey("status")) {
|
||||||
|
todoRepository.updateStatus(id, (String) updates.get("status"));
|
||||||
|
}
|
||||||
|
if (updates.containsKey("title")) {
|
||||||
|
todoRepository.updateTitle(id, (String) updates.get("title"));
|
||||||
|
}
|
||||||
|
if (updates.containsKey("priority")) {
|
||||||
|
todoRepository.updatePriority(id, (String) updates.get("priority"));
|
||||||
|
}
|
||||||
|
if (updates.containsKey("dueDate")) {
|
||||||
|
todoRepository.updateDueDate(id, (String) updates.get("dueDate"));
|
||||||
|
}
|
||||||
|
return todoRepository.findById(userId, id);
|
||||||
|
}).subscribeOn(Schedulers.boundedElastic());
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<Void> delete(String userId, String id) {
|
public Mono<Void> delete(String userId, String id) {
|
||||||
// TODO: Delete todo and subtasks
|
return Mono.fromRunnable(() -> {
|
||||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
Map<String, Object> todo = todoRepository.findById(userId, id);
|
||||||
|
if (todo == null) {
|
||||||
|
throw new AppException(HttpStatus.NOT_FOUND, "Todo not found");
|
||||||
|
}
|
||||||
|
todoRepository.delete(userId, id);
|
||||||
|
}).subscribeOn(Schedulers.boundedElastic()).then();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Mono<List<Map<String, Object>>> getSubtasks(String userId, String id) {
|
public Mono<List<Map<String, Object>>> getSubtasks(String userId, String id) {
|
||||||
// TODO: List subtasks for todo
|
return Mono.fromCallable(() -> {
|
||||||
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
|
Map<String, Object> todo = todoRepository.findById(userId, id);
|
||||||
|
if (todo == null) {
|
||||||
|
throw new AppException(HttpStatus.NOT_FOUND, "Todo not found");
|
||||||
|
}
|
||||||
|
return todoRepository.findSubtasks(userId, id);
|
||||||
|
}).subscribeOn(Schedulers.boundedElastic());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package com.sundol.service;
|
||||||
|
|
||||||
|
import com.microsoft.playwright.Page;
|
||||||
|
import org.jsoup.Jsoup;
|
||||||
|
import org.jsoup.nodes.Document;
|
||||||
|
import org.jsoup.nodes.Element;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.reactive.function.client.WebClient;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class WebCrawlerService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(WebCrawlerService.class);
|
||||||
|
private static final String JINA_READER_BASE = "https://r.jina.ai/";
|
||||||
|
private static final int MIN_CONTENT_LENGTH = 100;
|
||||||
|
private static final java.util.List<String> ERROR_PATTERNS = java.util.List.of(
|
||||||
|
"access denied", "403 forbidden", "you don't have permission",
|
||||||
|
"error 403", "error 401", "unauthorized", "captcha",
|
||||||
|
"please enable javascript", "checking your browser",
|
||||||
|
"attention required", "just a moment",
|
||||||
|
"technical difficulty", "page not found", "404 not found"
|
||||||
|
);
|
||||||
|
|
||||||
|
private final WebClient webClient;
|
||||||
|
private final PlaywrightBrowserService browserService;
|
||||||
|
|
||||||
|
@Value("${jina.reader.api-key:}")
|
||||||
|
private String jinaApiKey;
|
||||||
|
|
||||||
|
public WebCrawlerService(PlaywrightBrowserService browserService) {
|
||||||
|
this.browserService = browserService;
|
||||||
|
this.webClient = WebClient.builder()
|
||||||
|
.codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(5 * 1024 * 1024))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String crawl(String url) throws IOException {
|
||||||
|
// 1차: Jsoup 시도
|
||||||
|
try {
|
||||||
|
String text = crawlWithJsoup(url);
|
||||||
|
if (isValidContent(text)) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
log.warn("Jsoup returned invalid content ({} chars), falling back to Jina Reader",
|
||||||
|
text != null ? text.length() : 0);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Jsoup crawl failed for {}: {}, falling back to Jina Reader", url, e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2차: Jina Reader fallback
|
||||||
|
try {
|
||||||
|
String text = crawlWithJinaReader(url);
|
||||||
|
if (isValidContent(text)) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
log.warn("Jina Reader returned invalid content ({} chars), falling back to Playwright",
|
||||||
|
text != null ? text.length() : 0);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Jina Reader failed for {}: {}, falling back to Playwright", url, e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3차: Playwright (싱글톤 브라우저 탭)
|
||||||
|
String playwrightText = crawlWithPlaywright(url);
|
||||||
|
if (!isValidContent(playwrightText)) {
|
||||||
|
throw new IOException("All crawl methods failed for " + url + " (error page detected from all 3 sources)");
|
||||||
|
}
|
||||||
|
return playwrightText;
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isValidContent(String text) {
|
||||||
|
if (text == null || text.length() < MIN_CONTENT_LENGTH) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 에러 페이지 패턴 감지 (앞 500자만 검사)
|
||||||
|
String preview = text.substring(0, Math.min(text.length(), 500)).toLowerCase();
|
||||||
|
for (String pattern : ERROR_PATTERNS) {
|
||||||
|
if (preview.contains(pattern)) {
|
||||||
|
log.warn("Error page detected: content contains '{}'", pattern);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String crawlWithJsoup(String url) throws IOException {
|
||||||
|
log.info("Crawling with Jsoup: {}", url);
|
||||||
|
Document doc = Jsoup.connect(url)
|
||||||
|
.userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36")
|
||||||
|
.timeout(15_000)
|
||||||
|
.followRedirects(true)
|
||||||
|
.get();
|
||||||
|
|
||||||
|
// Remove non-content elements
|
||||||
|
doc.select("nav, footer, header, script, style, .ad, #cookie-banner, .sidebar, .comments").remove();
|
||||||
|
|
||||||
|
// Prefer article body
|
||||||
|
Element article = doc.selectFirst("article, main, .post-content, .article-body, .entry-content");
|
||||||
|
String text = (article != null ? article : doc.body()).text();
|
||||||
|
|
||||||
|
String title = doc.title();
|
||||||
|
log.info("Jsoup crawled '{}' - {} chars", title, text.length());
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String crawlWithJinaReader(String url) throws IOException {
|
||||||
|
log.info("Crawling with Jina Reader: {}", url);
|
||||||
|
try {
|
||||||
|
WebClient.RequestHeadersSpec<?> request = webClient.get()
|
||||||
|
.uri(JINA_READER_BASE + url)
|
||||||
|
.header("Accept", "text/plain");
|
||||||
|
|
||||||
|
if (jinaApiKey != null && !jinaApiKey.isBlank()) {
|
||||||
|
request = request.header("Authorization", "Bearer " + jinaApiKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
String result = ((WebClient.RequestHeadersSpec<?>) request)
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(String.class)
|
||||||
|
.timeout(Duration.ofSeconds(30))
|
||||||
|
.block();
|
||||||
|
|
||||||
|
if (result == null || result.isBlank()) {
|
||||||
|
throw new IOException("Jina Reader returned empty response for: " + url);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Jina Reader crawled {} - {} chars", url, result.length());
|
||||||
|
return result;
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IOException("Jina Reader failed for " + url + ": " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String crawlWithPlaywright(String url) throws IOException {
|
||||||
|
log.info("Crawling with Playwright: {}", url);
|
||||||
|
Page page = browserService.openPage(url);
|
||||||
|
try {
|
||||||
|
// JS 실행으로 본문 텍스트 추출
|
||||||
|
String text = page.evaluate("() => {" +
|
||||||
|
" ['nav','footer','header','script','style','.ad','#cookie-banner','.sidebar','.comments']" +
|
||||||
|
" .forEach(sel => document.querySelectorAll(sel).forEach(el => el.remove()));" +
|
||||||
|
" const article = document.querySelector('article, main, .post-content, .article-body, .entry-content');" +
|
||||||
|
" return (article || document.body).innerText;" +
|
||||||
|
"}").toString();
|
||||||
|
|
||||||
|
log.info("Playwright crawled {} - {} chars", url, text.length());
|
||||||
|
|
||||||
|
if (text == null || text.isBlank()) {
|
||||||
|
throw new IOException("Playwright returned empty content for: " + url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return text;
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
page.navigate("about:blank");
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
page.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String extractTitle(String url) throws IOException {
|
||||||
|
// Jsoup으로 제목만 가져오기 (가벼움)
|
||||||
|
try {
|
||||||
|
Document doc = Jsoup.connect(url)
|
||||||
|
.userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36")
|
||||||
|
.timeout(10_000)
|
||||||
|
.get();
|
||||||
|
return doc.title();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Title extraction via Jsoup failed for {}, trying Jina Reader", url);
|
||||||
|
// Jina Reader 응답에서 첫 줄을 제목으로 사용
|
||||||
|
try {
|
||||||
|
String content = crawlWithJinaReader(url);
|
||||||
|
String firstLine = content.strip().split("\\r?\\n", 2)[0].strip();
|
||||||
|
if (firstLine.startsWith("Title:")) {
|
||||||
|
return firstLine.substring(6).strip();
|
||||||
|
}
|
||||||
|
return firstLine.length() > 80 ? firstLine.substring(0, 77) + "..." : firstLine;
|
||||||
|
} catch (Exception e2) {
|
||||||
|
log.warn("Jina Reader title extraction also failed, trying Playwright", e2);
|
||||||
|
// Playwright 싱글톤 브라우저로 제목 추출
|
||||||
|
Page page = browserService.openPage(url);
|
||||||
|
try {
|
||||||
|
return page.title();
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
page.navigate("about:blank");
|
||||||
|
} catch (Exception ignored2) {
|
||||||
|
page.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,590 @@
|
|||||||
|
package com.sundol.service;
|
||||||
|
|
||||||
|
import com.microsoft.playwright.Page;
|
||||||
|
import com.microsoft.playwright.options.WaitUntilState;
|
||||||
|
import io.github.thoroldvix.api.TranscriptApiFactory;
|
||||||
|
import io.github.thoroldvix.api.TranscriptContent;
|
||||||
|
import io.github.thoroldvix.api.TranscriptList;
|
||||||
|
import io.github.thoroldvix.api.Transcript;
|
||||||
|
import io.github.thoroldvix.api.YoutubeTranscriptApi;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.net.URLDecoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class YouTubeTranscriptService {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(YouTubeTranscriptService.class);
|
||||||
|
|
||||||
|
private static final String[] PREFERRED_LANGS = {"ko", "en"};
|
||||||
|
|
||||||
|
private static final Pattern CAPTION_TRACK_PATTERN =
|
||||||
|
Pattern.compile("\"captionTracks\":\\s*\\[(.*?)]", Pattern.DOTALL);
|
||||||
|
private static final Pattern BASE_URL_PATTERN =
|
||||||
|
Pattern.compile("\"baseUrl\":\\s*\"(.*?)\"");
|
||||||
|
private static final Pattern LANG_PATTERN =
|
||||||
|
Pattern.compile("\"languageCode\":\\s*\"(.*?)\"");
|
||||||
|
private static final Pattern XML_TEXT_PATTERN =
|
||||||
|
Pattern.compile("<text[^>]*>(.*?)</text>", Pattern.DOTALL);
|
||||||
|
|
||||||
|
private final YoutubeTranscriptApi transcriptApi = TranscriptApiFactory.createDefault();
|
||||||
|
private final PlaywrightBrowserService browserService;
|
||||||
|
private final String extractTranscriptJs;
|
||||||
|
private final String transcriptFromPageJs;
|
||||||
|
|
||||||
|
public YouTubeTranscriptService(PlaywrightBrowserService browserService) {
|
||||||
|
this.browserService = browserService;
|
||||||
|
this.extractTranscriptJs = loadResource("youtube-transcript-extract.js");
|
||||||
|
this.transcriptFromPageJs = loadResource("youtube-transcript-from-page.js");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String loadResource(String name) {
|
||||||
|
try (InputStream is = getClass().getClassLoader().getResourceAsStream(name)) {
|
||||||
|
if (is == null) throw new RuntimeException("Resource not found: " + name);
|
||||||
|
return new String(is.readAllBytes(), StandardCharsets.UTF_8);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new RuntimeException("Failed to load resource: " + name, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String fetchTranscript(String youtubeUrl) throws IOException {
|
||||||
|
String videoId = extractVideoId(youtubeUrl);
|
||||||
|
if (videoId == null) {
|
||||||
|
throw new IOException("유효하지 않은 YouTube URL입니다: " + youtubeUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Fetching YouTube transcript for videoId: {}", videoId);
|
||||||
|
|
||||||
|
// 1차: youtube-transcript-api 라이브러리
|
||||||
|
try {
|
||||||
|
String transcript = fetchWithApi(videoId);
|
||||||
|
if (transcript != null && !transcript.isBlank()) {
|
||||||
|
log.info("Successfully fetched transcript via API: {} chars", transcript.length());
|
||||||
|
return transcript;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("youtube-transcript-api failed for {}: {}", videoId, e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2차 fallback: Playwright head 모드 (싱글톤 브라우저 탭)
|
||||||
|
log.info("Falling back to Playwright for videoId: {}", videoId);
|
||||||
|
return fetchWithPlaywright(videoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String fetchWithApi(String videoId) {
|
||||||
|
TranscriptList transcriptList;
|
||||||
|
try {
|
||||||
|
transcriptList = transcriptApi.listTranscripts(videoId);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Cannot list transcripts for {}: {}", videoId, e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// manual(수동 자막) 먼저 시도, 없으면 generated(자동 생성)
|
||||||
|
String result = fetchTranscriptByType(transcriptList, true);
|
||||||
|
if (result != null) return result;
|
||||||
|
return fetchTranscriptByType(transcriptList, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String fetchTranscriptByType(TranscriptList list, boolean manual) {
|
||||||
|
Transcript picked;
|
||||||
|
try {
|
||||||
|
picked = manual ? list.findManualTranscript(PREFERRED_LANGS)
|
||||||
|
: list.findGeneratedTranscript(PREFERRED_LANGS);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
TranscriptContent content = picked.fetch();
|
||||||
|
String text = content.getContent().stream()
|
||||||
|
.map(TranscriptContent.Fragment::getText)
|
||||||
|
.collect(Collectors.joining(" "));
|
||||||
|
if (text.isBlank()) return null;
|
||||||
|
String label = manual ? "manual" : "generated";
|
||||||
|
log.info("Transcript source: {} ({})", label, picked.getLanguageCode());
|
||||||
|
return text;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to fetch transcript for language {}: {}",
|
||||||
|
picked.getLanguageCode(), e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String fetchWithPlaywright(String videoId) throws IOException {
|
||||||
|
String watchUrl = "https://www.youtube.com/watch?v=" + videoId;
|
||||||
|
|
||||||
|
// YouTube는 광고 때문에 NETWORKIDLE에 도달하지 못할 수 있으므로 DOMCONTENTLOADED 사용
|
||||||
|
Page page = browserService.openPage(watchUrl, 60_000, WaitUntilState.DOMCONTENTLOADED);
|
||||||
|
try {
|
||||||
|
log.info("Playwright fetched YouTube page for videoId: {}", videoId);
|
||||||
|
// DOM 로드 후 추가 대기 (JS 렌더링)
|
||||||
|
page.waitForTimeout(3000);
|
||||||
|
|
||||||
|
// Step 0: 동영상 재생 시작 (봇 판정 우회)
|
||||||
|
playVideo(page);
|
||||||
|
|
||||||
|
// Step 1: 광고 대기 (최대 60초)
|
||||||
|
waitForAdsToFinish(page);
|
||||||
|
|
||||||
|
// Step 2: 실제 영상 재생 확인 (최대 15초)
|
||||||
|
waitForVideoPlaying(page);
|
||||||
|
|
||||||
|
// Step 3: 페이지 내 JS에서 ytInitialPlayerResponse로 자막 데이터 직접 추출
|
||||||
|
String transcript = fetchTranscriptFromPageJs(page);
|
||||||
|
if (transcript != null && !transcript.isBlank()) {
|
||||||
|
log.info("Successfully fetched transcript via page JS: {} chars", transcript.length());
|
||||||
|
return transcript;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: 자막 패널 열어서 텍스트 추출 (확인 루프)
|
||||||
|
transcript = extractTranscriptFromPanel(page);
|
||||||
|
if (transcript != null && !transcript.isBlank()) {
|
||||||
|
log.info("Successfully fetched transcript via panel: {} chars", transcript.length());
|
||||||
|
return transcript;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new IOException("자막 텍스트를 가져올 수 없습니다.");
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
page.navigate("about:blank");
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
page.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 페이지 내 ytInitialPlayerResponse에서 자막 데이터를 직접 추출한다.
|
||||||
|
* 브라우저 JS 컨텍스트에서 fetch하므로 쿠키/세션 유지.
|
||||||
|
*/
|
||||||
|
private String fetchTranscriptFromPageJs(Page page) {
|
||||||
|
try {
|
||||||
|
Object result = page.evaluate(transcriptFromPageJs);
|
||||||
|
if (result == null) return null;
|
||||||
|
|
||||||
|
String resultStr = result.toString();
|
||||||
|
log.info("Page JS transcript result: {} chars", resultStr.length());
|
||||||
|
|
||||||
|
if (resultStr.startsWith("{")) {
|
||||||
|
// JSON 응답 파싱
|
||||||
|
var jsonNode = new com.fasterxml.jackson.databind.ObjectMapper().readTree(resultStr);
|
||||||
|
if (jsonNode.has("error")) {
|
||||||
|
log.warn("Page JS transcript error: {}", jsonNode.get("error").asText());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String type = jsonNode.has("type") ? jsonNode.get("type").asText() : "";
|
||||||
|
String data = jsonNode.has("data") ? jsonNode.get("data").asText() : "";
|
||||||
|
String lang = jsonNode.has("lang") ? jsonNode.get("lang").asText() : "";
|
||||||
|
log.info("Page JS transcript type={}, lang={}, data={} chars", type, lang, data.length());
|
||||||
|
|
||||||
|
if (data.isEmpty()) return null;
|
||||||
|
|
||||||
|
if ("json3".equals(type)) {
|
||||||
|
return parseTranscriptJson(data);
|
||||||
|
} else if ("xml".equals(type)) {
|
||||||
|
return parseTranscriptXml(data);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// 문자열 직접 반환된 경우
|
||||||
|
return resultStr.isBlank() ? null : resultStr;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("fetchTranscriptFromPageJs failed: {}", e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 동영상 재생을 시작한다. 실제 사용자처럼 행동하여 봇 판정을 우회.
|
||||||
|
*/
|
||||||
|
private void playVideo(Page page) {
|
||||||
|
try {
|
||||||
|
// 쿠키 동의 팝업 닫기
|
||||||
|
page.evaluate(
|
||||||
|
"() => { " +
|
||||||
|
" var agreeBtn = document.querySelector('button[aria-label*=\"Accept\"], button[aria-label*=\"동의\"], tp-yt-paper-button[aria-label*=\"Agree\"]'); " +
|
||||||
|
" if (agreeBtn) agreeBtn.click(); " +
|
||||||
|
"}"
|
||||||
|
);
|
||||||
|
page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// 마우스를 플레이어 위로 이동 (호버 효과)
|
||||||
|
page.evaluate(
|
||||||
|
"() => { " +
|
||||||
|
" var player = document.querySelector('#movie_player, .html5-video-player'); " +
|
||||||
|
" if (player) player.dispatchEvent(new MouseEvent('mouseover', {bubbles: true})); " +
|
||||||
|
"}"
|
||||||
|
);
|
||||||
|
page.waitForTimeout(500);
|
||||||
|
|
||||||
|
// 재생 버튼 클릭 또는 영상 영역 클릭
|
||||||
|
page.evaluate(
|
||||||
|
"() => { " +
|
||||||
|
" var playBtn = document.querySelector('.ytp-play-button, .ytp-large-play-button'); " +
|
||||||
|
" if (playBtn) { playBtn.click(); return 'play_clicked'; } " +
|
||||||
|
" var video = document.querySelector('video'); " +
|
||||||
|
" if (video) { video.play(); return 'video_play'; } " +
|
||||||
|
" var player = document.querySelector('#movie_player'); " +
|
||||||
|
" if (player) { player.click(); return 'player_clicked'; } " +
|
||||||
|
" return 'nothing'; " +
|
||||||
|
"}"
|
||||||
|
);
|
||||||
|
log.info("Video play initiated");
|
||||||
|
page.waitForTimeout(2000);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("playVideo error (non-fatal): {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 실제 영상이 재생 중인지 확인한다. (최대 15초)
|
||||||
|
* 재생 시간이 변화하면 재생 중으로 판단.
|
||||||
|
*/
|
||||||
|
private void waitForVideoPlaying(Page page) {
|
||||||
|
log.info("Waiting for video to start playing...");
|
||||||
|
try {
|
||||||
|
for (int i = 0; i < 5; i++) {
|
||||||
|
Object result = page.evaluate(
|
||||||
|
"() => { " +
|
||||||
|
" var video = document.querySelector('video'); " +
|
||||||
|
" if (!video) return 'no_video'; " +
|
||||||
|
" if (video.paused) return 'paused:' + video.currentTime; " +
|
||||||
|
" return 'playing:' + video.currentTime; " +
|
||||||
|
"}"
|
||||||
|
);
|
||||||
|
String status = result != null ? result.toString() : "unknown";
|
||||||
|
log.info("Video status: {}", status);
|
||||||
|
|
||||||
|
if (status.startsWith("playing:")) {
|
||||||
|
// 재생 시간이 0보다 크면 실제 재생 중
|
||||||
|
String timeStr = status.substring(8);
|
||||||
|
try {
|
||||||
|
double time = Double.parseDouble(timeStr);
|
||||||
|
if (time > 0.5) {
|
||||||
|
log.info("Video is playing at {}s", time);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (NumberFormatException ignored) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.startsWith("paused")) {
|
||||||
|
// 일시정지 상태면 다시 재생 시도
|
||||||
|
page.evaluate(
|
||||||
|
"() => { " +
|
||||||
|
" var video = document.querySelector('video'); " +
|
||||||
|
" if (video) video.play(); " +
|
||||||
|
"}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
page.waitForTimeout(3000);
|
||||||
|
}
|
||||||
|
log.info("Video play wait finished");
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("waitForVideoPlaying error (non-fatal): {}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* YouTube 광고가 재생 중이면 끝날 때까지 대기한다. (최대 60초)
|
||||||
|
* 스킵 버튼이 나타나면 클릭한다.
|
||||||
|
*/
|
||||||
|
private void waitForAdsToFinish(Page page) {
|
||||||
|
log.info("Checking for YouTube ads...");
|
||||||
|
for (int i = 0; i < 30; i++) { // 최대 60초 (2초 간격)
|
||||||
|
try {
|
||||||
|
Object adResult = page.evaluate(
|
||||||
|
"() => { " +
|
||||||
|
" // 스킵 버튼 찾기 (여러 버전 대응) " +
|
||||||
|
" var skipBtns = document.querySelectorAll('.ytp-skip-ad-button, .ytp-ad-skip-button, .ytp-ad-skip-button-modern, .ytp-ad-skip-button-container button, [class*=\"skip\"][class*=\"ad\"] button'); " +
|
||||||
|
" for (var i = 0; i < skipBtns.length; i++) { " +
|
||||||
|
" if (skipBtns[i].offsetParent !== null) { skipBtns[i].click(); return 'skipped'; } " +
|
||||||
|
" } " +
|
||||||
|
" // 광고 재생 중 확인: #movie_player에 ad-showing 클래스 " +
|
||||||
|
" var player = document.querySelector('#movie_player'); " +
|
||||||
|
" if (player && player.classList.contains('ad-showing')) return 'ad_playing'; " +
|
||||||
|
" // 광고 오버레이 확인 " +
|
||||||
|
" var adOverlay = document.querySelector('.ytp-ad-player-overlay'); " +
|
||||||
|
" if (adOverlay && adOverlay.offsetHeight > 0) return 'ad_playing'; " +
|
||||||
|
" // video duration으로 확인 (광고는 보통 짧음) " +
|
||||||
|
" var video = document.querySelector('video'); " +
|
||||||
|
" if (video && video.duration > 0 && video.duration < 120 && !video.paused) { " +
|
||||||
|
" var adText = document.querySelector('.ytp-ad-text, .ytp-ad-preview-text, .ytp-ad-simple-ad-badge'); " +
|
||||||
|
" if (adText) return 'ad_playing'; " +
|
||||||
|
" } " +
|
||||||
|
" return 'no_ad'; " +
|
||||||
|
"}"
|
||||||
|
);
|
||||||
|
String status = adResult != null ? adResult.toString() : "no_ad";
|
||||||
|
|
||||||
|
if ("skipped".equals(status)) {
|
||||||
|
log.info("Ad skipped at attempt {}", i + 1);
|
||||||
|
page.waitForTimeout(3000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ("no_ad".equals(status)) {
|
||||||
|
if (i == 0) {
|
||||||
|
log.info("No ads detected");
|
||||||
|
} else {
|
||||||
|
log.info("Ads finished after {} attempts", i + 1);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (i % 5 == 0) log.info("Ad still playing, waiting... ({}s)", i * 2);
|
||||||
|
page.waitForTimeout(2000);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Ad check error: {}", e.getMessage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.warn("Ad wait timeout (60s), proceeding anyway");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* YouTube 페이지에서 '자막 표시' 패널을 열고 텍스트를 추출한다.
|
||||||
|
*/
|
||||||
|
private String extractTranscriptFromPanel(Page page) {
|
||||||
|
try {
|
||||||
|
page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Step 1: 설명란 펼치기
|
||||||
|
page.evaluate(
|
||||||
|
"() => { " +
|
||||||
|
" var exp = document.querySelector('#expand, tp-yt-paper-button#expand, #description-inline-expander #expand'); " +
|
||||||
|
" if (exp) exp.click(); " +
|
||||||
|
"}"
|
||||||
|
);
|
||||||
|
page.waitForTimeout(1500);
|
||||||
|
|
||||||
|
// Step 2: '스크립트 표시' / 'Show transcript' 버튼 찾아 클릭
|
||||||
|
Object result = page.evaluate(
|
||||||
|
"() => { " +
|
||||||
|
" var keywords = ['Show transcript', 'transcript', '스크립트 표시', '자막 보기', '자막']; " +
|
||||||
|
" var allBtns = document.querySelectorAll('button, ytd-button-renderer, tp-yt-paper-button'); " +
|
||||||
|
" for (var i = 0; i < allBtns.length; i++) { " +
|
||||||
|
" var t = (allBtns[i].innerText || allBtns[i].textContent || '').trim().toLowerCase(); " +
|
||||||
|
" for (var j = 0; j < keywords.length; j++) { " +
|
||||||
|
" if (t.indexOf(keywords[j].toLowerCase()) !== -1) { " +
|
||||||
|
" allBtns[i].click(); " +
|
||||||
|
" return 'clicked:' + t; " +
|
||||||
|
" } " +
|
||||||
|
" } " +
|
||||||
|
" } " +
|
||||||
|
" return 'not_found'; " +
|
||||||
|
"}"
|
||||||
|
);
|
||||||
|
log.info("Transcript panel button result: {}", result);
|
||||||
|
|
||||||
|
// 패널이 HIDDEN 상태일 수 있으므로 직접 visibility를 변경하고 데이터 로드 트리거
|
||||||
|
page.waitForTimeout(1500);
|
||||||
|
page.evaluate(
|
||||||
|
"() => { " +
|
||||||
|
" var panel = document.querySelector('ytd-engagement-panel-section-list-renderer[target-id=\"engagement-panel-searchable-transcript\"]'); " +
|
||||||
|
" if (panel) { " +
|
||||||
|
" panel.setAttribute('visibility', 'ENGAGEMENT_PANEL_VISIBILITY_EXPANDED'); " +
|
||||||
|
" panel.removeAttribute('hidden'); " +
|
||||||
|
" panel.style.display = ''; " +
|
||||||
|
" } " +
|
||||||
|
"}"
|
||||||
|
);
|
||||||
|
page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
if (result != null && result.toString().startsWith("not_found")) {
|
||||||
|
// 대안: 동영상 메뉴(...) 버튼 → 스크립트 열기
|
||||||
|
page.evaluate(
|
||||||
|
"() => { " +
|
||||||
|
" var menuBtn = document.querySelector('ytd-menu-renderer yt-icon-button button, #menu button'); " +
|
||||||
|
" if (menuBtn) menuBtn.click(); " +
|
||||||
|
"}"
|
||||||
|
);
|
||||||
|
page.waitForTimeout(1000);
|
||||||
|
page.evaluate(
|
||||||
|
"() => { " +
|
||||||
|
" var items = document.querySelectorAll('tp-yt-paper-item, ytd-menu-service-item-renderer'); " +
|
||||||
|
" for (var i = 0; i < items.length; i++) { " +
|
||||||
|
" var t = (items[i].innerText || '').toLowerCase(); " +
|
||||||
|
" if (t.indexOf('transcript') !== -1 || t.indexOf('스크립트') !== -1) { " +
|
||||||
|
" items[i].click(); " +
|
||||||
|
" return 'menu_clicked'; " +
|
||||||
|
" } " +
|
||||||
|
" } " +
|
||||||
|
" return 'not_found'; " +
|
||||||
|
"}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: 자막 텍스트가 나타날 때까지 확인 루프 (최대 30초)
|
||||||
|
String transcript = null;
|
||||||
|
for (int attempt = 0; attempt < 15; attempt++) {
|
||||||
|
page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// 디버그: 트랜스크립트 패널 DOM 상태 확인
|
||||||
|
if (attempt == 0 || attempt == 3) {
|
||||||
|
Object debugInfo = page.evaluate(
|
||||||
|
"() => { " +
|
||||||
|
" var panel = document.querySelector('ytd-engagement-panel-section-list-renderer[target-id=\"engagement-panel-searchable-transcript\"]'); " +
|
||||||
|
" if (!panel) return 'panel:NOT_FOUND'; " +
|
||||||
|
" var vis = panel.getAttribute('visibility'); " +
|
||||||
|
" var segs = panel.querySelectorAll('[class*=\"segment\"], [class*=\"cue\"], yt-formatted-string'); " +
|
||||||
|
" var body = panel.querySelector('#body, #content'); " +
|
||||||
|
" var bodyHtml = body ? body.innerHTML.substring(0, 500) : 'no_body'; " +
|
||||||
|
" return 'panel:' + vis + ' segs:' + segs.length + ' html:' + bodyHtml; " +
|
||||||
|
"}"
|
||||||
|
);
|
||||||
|
log.info("Debug transcript panel (attempt {}): {}", attempt + 1, debugInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
Object transcriptObj = page.evaluate(extractTranscriptJs);
|
||||||
|
|
||||||
|
transcript = transcriptObj != null ? transcriptObj.toString() : "";
|
||||||
|
if (!transcript.isBlank()) {
|
||||||
|
log.info("Transcript from panel: {} chars (attempt {})", transcript.length(), attempt + 1);
|
||||||
|
return transcript;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 패널이 아직 안 열렸으면 다시 시도
|
||||||
|
if (attempt == 2 || attempt == 5 || attempt == 9) {
|
||||||
|
log.info("Retrying panel open at attempt {}", attempt + 1);
|
||||||
|
// 패널 visibility 강제 변경 + Show transcript 버튼 재클릭
|
||||||
|
page.evaluate(
|
||||||
|
"() => { " +
|
||||||
|
" var panel = document.querySelector('ytd-engagement-panel-section-list-renderer[target-id=\"engagement-panel-searchable-transcript\"]'); " +
|
||||||
|
" if (panel) { " +
|
||||||
|
" panel.setAttribute('visibility', 'ENGAGEMENT_PANEL_VISIBILITY_EXPANDED'); " +
|
||||||
|
" panel.removeAttribute('hidden'); " +
|
||||||
|
" panel.style.display = ''; " +
|
||||||
|
" var cont = panel.querySelector('ytd-continuation-item-renderer'); " +
|
||||||
|
" if (cont) cont.click(); " +
|
||||||
|
" } " +
|
||||||
|
" var keywords = ['Show transcript', 'transcript', '스크립트 표시', '자막 보기']; " +
|
||||||
|
" var allBtns = document.querySelectorAll('button, ytd-button-renderer, tp-yt-paper-button'); " +
|
||||||
|
" for (var i = 0; i < allBtns.length; i++) { " +
|
||||||
|
" var t = (allBtns[i].innerText || '').trim().toLowerCase(); " +
|
||||||
|
" for (var j = 0; j < keywords.length; j++) { " +
|
||||||
|
" if (t.indexOf(keywords[j].toLowerCase()) !== -1) { " +
|
||||||
|
" allBtns[i].click(); return 'retried'; " +
|
||||||
|
" } " +
|
||||||
|
" } " +
|
||||||
|
" } " +
|
||||||
|
"}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Transcript panel extraction timed out after 30s");
|
||||||
|
return null;
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to extract transcript from panel: {}", e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* YouTube timedtext API의 fmt=json3 응답을 파싱한다.
|
||||||
|
*/
|
||||||
|
private String parseTranscriptJson(String json) {
|
||||||
|
try {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
// json3 형식: {"events":[{"segs":[{"utf8":"text"}]},...]}
|
||||||
|
Pattern segPattern = Pattern.compile("\"utf8\":\\s*\"(.*?)\"");
|
||||||
|
Matcher matcher = segPattern.matcher(json);
|
||||||
|
while (matcher.find()) {
|
||||||
|
String text = matcher.group(1)
|
||||||
|
.replace("\\n", " ")
|
||||||
|
.replace("\\\"", "\"")
|
||||||
|
.trim();
|
||||||
|
if (!text.isEmpty() && !text.equals("\n")) {
|
||||||
|
if (sb.length() > 0) sb.append(" ");
|
||||||
|
sb.append(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to parse transcript JSON: {}", e.getMessage());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private String selectCaptionUrl(String captionTracksJson) {
|
||||||
|
String[] tracks = captionTracksJson.split("\\},\\s*\\{");
|
||||||
|
|
||||||
|
String koUrl = null;
|
||||||
|
String enUrl = null;
|
||||||
|
String firstUrl = null;
|
||||||
|
|
||||||
|
for (String track : tracks) {
|
||||||
|
Matcher urlMatcher = BASE_URL_PATTERN.matcher(track);
|
||||||
|
Matcher langMatcher = LANG_PATTERN.matcher(track);
|
||||||
|
|
||||||
|
if (urlMatcher.find()) {
|
||||||
|
String url = urlMatcher.group(1);
|
||||||
|
if (firstUrl == null) firstUrl = url;
|
||||||
|
|
||||||
|
if (langMatcher.find()) {
|
||||||
|
String lang = langMatcher.group(1);
|
||||||
|
if (lang.startsWith("ko") && koUrl == null) koUrl = url;
|
||||||
|
if (lang.startsWith("en") && enUrl == null) enUrl = url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (koUrl != null) return koUrl;
|
||||||
|
if (enUrl != null) return enUrl;
|
||||||
|
return firstUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String parseTranscriptXml(String xml) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
Matcher matcher = XML_TEXT_PATTERN.matcher(xml);
|
||||||
|
while (matcher.find()) {
|
||||||
|
String text = matcher.group(1)
|
||||||
|
.replace("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
.replace(""", "\"")
|
||||||
|
.replace("'", "'")
|
||||||
|
.replace("\n", " ")
|
||||||
|
.trim();
|
||||||
|
if (!text.isEmpty()) {
|
||||||
|
if (sb.length() > 0) sb.append(" ");
|
||||||
|
sb.append(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractVideoId(String url) {
|
||||||
|
if (url == null || url.isBlank()) return null;
|
||||||
|
try {
|
||||||
|
java.net.URI uri = new java.net.URI(url);
|
||||||
|
String host = uri.getHost();
|
||||||
|
if (host == null) return null;
|
||||||
|
if (host.equals("youtu.be")) {
|
||||||
|
String path = uri.getPath();
|
||||||
|
return path != null && path.length() > 1 ? path.substring(1) : null;
|
||||||
|
}
|
||||||
|
if (host.contains("youtube.com")) {
|
||||||
|
String query = uri.getQuery();
|
||||||
|
if (query == null) return null;
|
||||||
|
for (String param : query.split("&")) {
|
||||||
|
String[] kv = param.split("=", 2);
|
||||||
|
if (kv.length == 2 && kv[0].equals("v")) {
|
||||||
|
return URLDecoder.decode(kv[1], StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to parse YouTube URL: {}", url);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,12 +12,26 @@ jwt:
|
|||||||
access-token-expiry: ${JWT_ACCESS_TOKEN_EXPIRY:900000}
|
access-token-expiry: ${JWT_ACCESS_TOKEN_EXPIRY:900000}
|
||||||
refresh-token-expiry: ${JWT_REFRESH_TOKEN_EXPIRY:604800000}
|
refresh-token-expiry: ${JWT_REFRESH_TOKEN_EXPIRY:604800000}
|
||||||
|
|
||||||
|
google:
|
||||||
|
client-id: ${GOOGLE_CLIENT_ID}
|
||||||
|
client-secret: ${GOOGLE_CLIENT_SECRET}
|
||||||
|
redirect-uri: ${GOOGLE_REDIRECT_URI:https://sundol.cloud-handson.com/login/callback}
|
||||||
|
|
||||||
cors:
|
cors:
|
||||||
origin: ${CORS_ORIGIN:http://localhost:3000}
|
origin: ${CORS_ORIGIN:http://localhost:3000}
|
||||||
|
|
||||||
oci:
|
oci:
|
||||||
compartment-id: ${OCI_COMPARTMENT_ID:}
|
compartment-id: ${OCI_COMPARTMENT_ID:}
|
||||||
region: ${OCI_REGION:ap-seoul-1}
|
region: ${OCI_REGION:ap-seoul-1}
|
||||||
|
genai:
|
||||||
|
api-key: ${OCI_GENAI_API_KEY:}
|
||||||
|
compartment: ${OCI_GENAI_COMPARTMENT:}
|
||||||
|
model: ${OCI_GENAI_MODEL:google.gemini-2.5-flash}
|
||||||
|
base-url: ${OCI_GENAI_BASE_URL:https://inference.generativeai.us-chicago-1.oci.oraclecloud.com/20231130/actions}
|
||||||
|
|
||||||
|
jina:
|
||||||
|
reader:
|
||||||
|
api-key: ${JINA_READER_API_KEY:}
|
||||||
|
|
||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
() => {
|
||||||
|
var selectors = [
|
||||||
|
'ytd-transcript-segment-renderer .segment-text',
|
||||||
|
'yt-formatted-string.segment-text',
|
||||||
|
'#segments-container yt-formatted-string',
|
||||||
|
'ytd-transcript-segment-list-renderer .segment-text',
|
||||||
|
'ytd-transcript-segment-renderer yt-formatted-string',
|
||||||
|
'ytd-engagement-panel-section-list-renderer yt-formatted-string.ytd-transcript-segment-renderer'
|
||||||
|
];
|
||||||
|
for (var s = 0; s < selectors.length; s++) {
|
||||||
|
try {
|
||||||
|
var segs = document.querySelectorAll(selectors[s]);
|
||||||
|
if (segs.length > 0) {
|
||||||
|
var texts = [];
|
||||||
|
for (var i = 0; i < segs.length; i++) {
|
||||||
|
var txt = segs[i].textContent.trim();
|
||||||
|
if (txt.length > 0) texts.push(txt);
|
||||||
|
}
|
||||||
|
if (texts.length > 0) return texts.join(' ');
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
// 최후 수단: engagement panel 내 모든 텍스트
|
||||||
|
var panel = document.querySelector('ytd-engagement-panel-section-list-renderer[target-id="engagement-panel-searchable-transcript"]');
|
||||||
|
if (panel) {
|
||||||
|
var body = panel.querySelector('#body, #content');
|
||||||
|
if (body) {
|
||||||
|
var allText = body.innerText.trim();
|
||||||
|
if (allText.length > 100) return allText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
() => {
|
||||||
|
try {
|
||||||
|
// 방법 1: ytInitialPlayerResponse에서 직접 자막 데이터 추출
|
||||||
|
var playerResponse = null;
|
||||||
|
if (typeof ytInitialPlayerResponse !== 'undefined') {
|
||||||
|
playerResponse = ytInitialPlayerResponse;
|
||||||
|
}
|
||||||
|
if (!playerResponse) {
|
||||||
|
// 페이지 소스에서 추출 시도
|
||||||
|
var scripts = document.querySelectorAll('script');
|
||||||
|
for (var i = 0; i < scripts.length; i++) {
|
||||||
|
var text = scripts[i].textContent;
|
||||||
|
if (text && text.indexOf('ytInitialPlayerResponse') !== -1) {
|
||||||
|
var match = text.match(/ytInitialPlayerResponse\s*=\s*(\{.*?\});/s);
|
||||||
|
if (match) {
|
||||||
|
try { playerResponse = JSON.parse(match[1]); } catch(e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!playerResponse) return JSON.stringify({error: 'no_player_response'});
|
||||||
|
|
||||||
|
// captionTracks 추출
|
||||||
|
var captions = playerResponse.captions;
|
||||||
|
if (!captions) return JSON.stringify({error: 'no_captions'});
|
||||||
|
|
||||||
|
var renderer = captions.playerCaptionsTracklistRenderer;
|
||||||
|
if (!renderer) return JSON.stringify({error: 'no_renderer'});
|
||||||
|
|
||||||
|
var tracks = renderer.captionTracks;
|
||||||
|
if (!tracks || tracks.length === 0) return JSON.stringify({error: 'no_tracks'});
|
||||||
|
|
||||||
|
// 언어 우선순위: ko > en > 첫 번째
|
||||||
|
var selectedTrack = null;
|
||||||
|
for (var t = 0; t < tracks.length; t++) {
|
||||||
|
if (tracks[t].languageCode === 'ko') { selectedTrack = tracks[t]; break; }
|
||||||
|
}
|
||||||
|
if (!selectedTrack) {
|
||||||
|
for (var t = 0; t < tracks.length; t++) {
|
||||||
|
if (tracks[t].languageCode === 'en') { selectedTrack = tracks[t]; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!selectedTrack) selectedTrack = tracks[0];
|
||||||
|
|
||||||
|
var baseUrl = selectedTrack.baseUrl;
|
||||||
|
if (!baseUrl) return JSON.stringify({error: 'no_base_url'});
|
||||||
|
|
||||||
|
// fmt=json3 추가
|
||||||
|
if (baseUrl.indexOf('fmt=') === -1) {
|
||||||
|
baseUrl += '&fmt=json3';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 브라우저 내에서 fetch로 자막 데이터 가져오기
|
||||||
|
return fetch(baseUrl, {credentials: 'include'})
|
||||||
|
.then(function(res) { return res.text(); })
|
||||||
|
.then(function(text) {
|
||||||
|
if (!text || text.length === 0) {
|
||||||
|
// fmt=json3 실패 시 fmt 없이 재시도 (XML)
|
||||||
|
var xmlUrl = baseUrl.replace('&fmt=json3', '');
|
||||||
|
return fetch(xmlUrl, {credentials: 'include'})
|
||||||
|
.then(function(res2) { return res2.text(); })
|
||||||
|
.then(function(xmlText) {
|
||||||
|
return JSON.stringify({type: 'xml', data: xmlText, lang: selectedTrack.languageCode});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return JSON.stringify({type: 'json3', data: text, lang: selectedTrack.languageCode});
|
||||||
|
});
|
||||||
|
} catch(e) {
|
||||||
|
return JSON.stringify({error: e.message});
|
||||||
|
}
|
||||||
|
}
|
||||||
58
sundol-frontend/build.sh
Executable file
58
sundol-frontend/build.sh
Executable file
@@ -0,0 +1,58 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
|
||||||
|
# .env 로드
|
||||||
|
ENV_FILE="$SCRIPT_DIR/../.env"
|
||||||
|
if [ -f "$ENV_FILE" ]; then
|
||||||
|
set -a && source "$ENV_FILE" && set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 필수 환경변수 검증
|
||||||
|
echo "=== [0/3] 환경변수 검증 ==="
|
||||||
|
REQUIRED_VARS=("NEXT_PUBLIC_GOOGLE_CLIENT_ID" "NEXT_PUBLIC_API_URL")
|
||||||
|
for var in "${REQUIRED_VARS[@]}"; do
|
||||||
|
if [ -z "${!var}" ]; then
|
||||||
|
echo "ERROR: $var 가 .env에 설정되어 있지 않습니다. 빌드를 중단합니다."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " $var = ${!var:0:20}..."
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "=== [1/3] Next.js 빌드 ==="
|
||||||
|
npx next build
|
||||||
|
|
||||||
|
echo "=== [2/3] 심볼릭 링크 생성 ==="
|
||||||
|
STANDALONE_DIR="$SCRIPT_DIR/.next/standalone"
|
||||||
|
|
||||||
|
# static 링크
|
||||||
|
STATIC_SRC="$SCRIPT_DIR/.next/static"
|
||||||
|
STATIC_DST="$STANDALONE_DIR/.next/static"
|
||||||
|
mkdir -p "$STANDALONE_DIR/.next"
|
||||||
|
if [ -L "$STATIC_DST" ] || [ -e "$STATIC_DST" ]; then
|
||||||
|
rm -rf "$STATIC_DST"
|
||||||
|
fi
|
||||||
|
ln -s "$STATIC_SRC" "$STATIC_DST"
|
||||||
|
echo "링크 생성 완료: $STATIC_DST -> $STATIC_SRC"
|
||||||
|
|
||||||
|
# standalone output이 subdirectory에 생성되는 경우 대응
|
||||||
|
if [ -d "$STANDALONE_DIR/sundol-frontend" ]; then
|
||||||
|
# server.js 링크
|
||||||
|
if [ ! -f "$STANDALONE_DIR/server.js" ] && [ -f "$STANDALONE_DIR/sundol-frontend/server.js" ]; then
|
||||||
|
ln -sf "$STANDALONE_DIR/sundol-frontend/server.js" "$STANDALONE_DIR/server.js"
|
||||||
|
echo "server.js 링크 생성 완료"
|
||||||
|
fi
|
||||||
|
# nested static 링크
|
||||||
|
NESTED_STATIC="$STANDALONE_DIR/sundol-frontend/.next/static"
|
||||||
|
if [ ! -L "$NESTED_STATIC" ] && [ ! -e "$NESTED_STATIC" ]; then
|
||||||
|
ln -s "$STATIC_SRC" "$NESTED_STATIC"
|
||||||
|
echo "nested static 링크 생성 완료"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== [3/3] PM2 재시작 ==="
|
||||||
|
pm2 restart sundol-frontend
|
||||||
|
|
||||||
|
echo "=== 빌드 완료 ==="
|
||||||
6
sundol-frontend/next-env.d.ts
vendored
Normal file
6
sundol-frontend/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
/// <reference path="./.next/types/routes.d.ts" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
7411
sundol-frontend/package-lock.json
generated
Normal file
7411
sundol-frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -9,23 +9,23 @@
|
|||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"axios": "^1.7.9",
|
||||||
|
"lucide-react": "^0.469.0",
|
||||||
"next": "^15.3.1",
|
"next": "^15.3.1",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"axios": "^1.7.9",
|
"react-markdown": "^9.1.0",
|
||||||
"zustand": "^5.0.3",
|
"zustand": "^5.0.3"
|
||||||
"react-markdown": "^9.0.3",
|
|
||||||
"lucide-react": "^0.469.0"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.7.3",
|
"@eslint/eslintrc": "^3",
|
||||||
|
"@tailwindcss/postcss": "^4.1.0",
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
"@types/react": "^19.1.0",
|
"@types/react": "^19.1.0",
|
||||||
"@types/react-dom": "^19.1.0",
|
"@types/react-dom": "^19.1.0",
|
||||||
"@tailwindcss/postcss": "^4.1.0",
|
|
||||||
"tailwindcss": "^4.1.0",
|
|
||||||
"eslint": "^9.17.0",
|
"eslint": "^9.17.0",
|
||||||
"eslint-config-next": "^15.3.1",
|
"eslint-config-next": "^15.3.1",
|
||||||
"@eslint/eslintrc": "^3"
|
"tailwindcss": "^4.1.0",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,297 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, useRef } from "react";
|
||||||
import AuthGuard from "@/components/auth-guard";
|
import AuthGuard from "@/components/auth-guard";
|
||||||
import NavBar from "@/components/nav-bar";
|
import NavBar from "@/components/nav-bar";
|
||||||
|
import { useApi } from "@/lib/use-api";
|
||||||
|
|
||||||
|
interface Session {
|
||||||
|
ID: string;
|
||||||
|
TITLE: string;
|
||||||
|
CREATED_AT: string;
|
||||||
|
UPDATED_AT: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
ID: string;
|
||||||
|
ROLE: string;
|
||||||
|
CONTENT: string;
|
||||||
|
SOURCE_CHUNKS: string | null;
|
||||||
|
CREATED_AT: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SourceChunk {
|
||||||
|
knowledgeItemId: string;
|
||||||
|
title: string;
|
||||||
|
chunkIndex: number;
|
||||||
|
distance: number;
|
||||||
|
}
|
||||||
|
|
||||||
export default function ChatPage() {
|
export default function ChatPage() {
|
||||||
|
const { request } = useApi();
|
||||||
|
const [sessions, setSessions] = useState<Session[]>([]);
|
||||||
|
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [loadingSessions, setLoadingSessions] = useState(true);
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollToBottom();
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
// Load sessions
|
||||||
|
useEffect(() => {
|
||||||
|
request<Session[]>({ method: "GET", url: "/api/chat/sessions" })
|
||||||
|
.then(setSessions)
|
||||||
|
.catch((err) => console.error("Failed to load sessions:", err))
|
||||||
|
.finally(() => setLoadingSessions(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load messages when session changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeSessionId) {
|
||||||
|
setMessages([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
request<Message[]>({ method: "GET", url: `/api/chat/sessions/${activeSessionId}/messages` })
|
||||||
|
.then(setMessages)
|
||||||
|
.catch((err) => console.error("Failed to load messages:", err));
|
||||||
|
}, [activeSessionId]);
|
||||||
|
|
||||||
|
const handleNewSession = async () => {
|
||||||
|
try {
|
||||||
|
const session = await request<Session>({ method: "POST", url: "/api/chat/sessions" });
|
||||||
|
setSessions((prev) => [session, ...prev]);
|
||||||
|
setActiveSessionId(session.ID);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to create session:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteSession = async (sessionId: string) => {
|
||||||
|
try {
|
||||||
|
await request({ method: "DELETE", url: `/api/chat/sessions/${sessionId}` });
|
||||||
|
setSessions((prev) => prev.filter((s) => s.ID !== sessionId));
|
||||||
|
if (activeSessionId === sessionId) {
|
||||||
|
setActiveSessionId(null);
|
||||||
|
setMessages([]);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete session:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!input.trim() || sending || !activeSessionId) return;
|
||||||
|
|
||||||
|
const userMessage = input.trim();
|
||||||
|
setInput("");
|
||||||
|
setSending(true);
|
||||||
|
|
||||||
|
// Optimistic UI: add user message immediately
|
||||||
|
const tempUserMsg: Message = {
|
||||||
|
ID: "temp-user",
|
||||||
|
ROLE: "user",
|
||||||
|
CONTENT: userMessage,
|
||||||
|
SOURCE_CHUNKS: null,
|
||||||
|
CREATED_AT: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
setMessages((prev) => [...prev, tempUserMsg]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await request<{ role: string; content: string; sourceChunks: string }>({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/chat/sessions/${activeSessionId}/messages`,
|
||||||
|
data: { content: userMessage },
|
||||||
|
});
|
||||||
|
|
||||||
|
const assistantMsg: Message = {
|
||||||
|
ID: "temp-assistant-" + Date.now(),
|
||||||
|
ROLE: "assistant",
|
||||||
|
CONTENT: response.content,
|
||||||
|
SOURCE_CHUNKS: response.sourceChunks,
|
||||||
|
CREATED_AT: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
setMessages((prev) => [...prev, assistantMsg]);
|
||||||
|
|
||||||
|
// Refresh sessions for updated title
|
||||||
|
request<Session[]>({ method: "GET", url: "/api/chat/sessions" })
|
||||||
|
.then(setSessions)
|
||||||
|
.catch(() => {});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to send message:", err);
|
||||||
|
const errorMsg: Message = {
|
||||||
|
ID: "temp-error",
|
||||||
|
ROLE: "assistant",
|
||||||
|
CONTENT: "메시지 전송에 실패했습니다.",
|
||||||
|
SOURCE_CHUNKS: null,
|
||||||
|
CREATED_AT: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
setMessages((prev) => [...prev, errorMsg]);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseSourceChunks = (json: string | null): SourceChunk[] => {
|
||||||
|
if (!json) return [];
|
||||||
|
try {
|
||||||
|
return JSON.parse(json);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthGuard>
|
<AuthGuard>
|
||||||
<NavBar />
|
<NavBar />
|
||||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
<main className="max-w-7xl mx-auto px-4 py-4 h-[calc(100vh-64px)] flex gap-4">
|
||||||
<h1 className="text-2xl font-bold mb-6">AI Chat</h1>
|
{/* Sidebar: Sessions */}
|
||||||
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)] min-h-[60vh] flex items-center justify-center">
|
<div className="w-64 flex-shrink-0 flex flex-col">
|
||||||
<p className="text-[var(--color-text-muted)]">Start a new conversation to ask questions about your knowledge base.</p>
|
<button
|
||||||
|
onClick={handleNewSession}
|
||||||
|
className="w-full px-4 py-2 mb-3 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg text-sm transition-colors"
|
||||||
|
>
|
||||||
|
+ New Chat
|
||||||
|
</button>
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-1">
|
||||||
|
{loadingSessions ? (
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)] px-2">Loading...</p>
|
||||||
|
) : sessions.length === 0 ? (
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)] px-2">No conversations yet</p>
|
||||||
|
) : (
|
||||||
|
sessions.map((s) => (
|
||||||
|
<div
|
||||||
|
key={s.ID}
|
||||||
|
className={`group flex items-center rounded-lg px-3 py-2 text-sm cursor-pointer transition-colors ${
|
||||||
|
activeSessionId === s.ID
|
||||||
|
? "bg-[var(--color-primary)]/20 text-[var(--color-primary)]"
|
||||||
|
: "hover:bg-[var(--color-bg-hover)] text-[var(--color-text-muted)]"
|
||||||
|
}`}
|
||||||
|
onClick={() => setActiveSessionId(s.ID)}
|
||||||
|
>
|
||||||
|
<span className="flex-1 truncate">{s.TITLE || "New Chat"}</span>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDeleteSession(s.ID);
|
||||||
|
}}
|
||||||
|
className="hidden group-hover:block text-red-400 hover:text-red-300 ml-2 text-xs"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Chat Area */}
|
||||||
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
|
{!activeSessionId ? (
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<h2 className="text-xl font-bold mb-2">AI Chat</h2>
|
||||||
|
<p className="text-[var(--color-text-muted)] mb-4">
|
||||||
|
Knowledge base를 기반으로 질문하세요.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={handleNewSession}
|
||||||
|
className="px-6 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Start a new chat
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Messages */}
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-4 py-4">
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<p className="text-center text-[var(--color-text-muted)] mt-20">
|
||||||
|
Knowledge base에 대해 질문해보세요.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{messages.map((msg) => (
|
||||||
|
<div
|
||||||
|
key={msg.ID}
|
||||||
|
className={`flex ${msg.ROLE === "user" ? "justify-end" : "justify-start"}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`max-w-[75%] rounded-xl px-4 py-3 ${
|
||||||
|
msg.ROLE === "user"
|
||||||
|
? "bg-[var(--color-primary)] text-white"
|
||||||
|
: "bg-[var(--color-bg-card)] border border-[var(--color-border)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="whitespace-pre-wrap text-sm">{msg.CONTENT}</p>
|
||||||
|
{/* Source chunks */}
|
||||||
|
{msg.ROLE === "assistant" && (() => {
|
||||||
|
const sources = parseSourceChunks(msg.SOURCE_CHUNKS);
|
||||||
|
if (sources.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="mt-2 pt-2 border-t border-[var(--color-border)]">
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)] mb-1">참조:</p>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{sources.map((src, i) => (
|
||||||
|
<a
|
||||||
|
key={i}
|
||||||
|
href={`/knowledge/${src.knowledgeItemId}`}
|
||||||
|
className="text-xs px-2 py-0.5 rounded bg-[var(--color-bg-hover)] text-[var(--color-text-muted)] hover:text-[var(--color-primary)]"
|
||||||
|
>
|
||||||
|
{src.title}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{sending && (
|
||||||
|
<div className="flex justify-start">
|
||||||
|
<div className="bg-[var(--color-bg-card)] border border-[var(--color-border)] rounded-xl px-4 py-3">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<div className="w-2 h-2 bg-[var(--color-text-muted)] rounded-full animate-bounce" style={{ animationDelay: "0ms" }} />
|
||||||
|
<div className="w-2 h-2 bg-[var(--color-text-muted)] rounded-full animate-bounce" style={{ animationDelay: "150ms" }} />
|
||||||
|
<div className="w-2 h-2 bg-[var(--color-text-muted)] rounded-full animate-bounce" style={{ animationDelay: "300ms" }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<div className="py-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && handleSend()}
|
||||||
|
placeholder="메시지를 입력하세요..."
|
||||||
|
disabled={sending}
|
||||||
|
className="flex-1 px-4 py-3 rounded-xl bg-[var(--color-bg-card)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={!input.trim() || sending}
|
||||||
|
className="px-6 py-3 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] disabled:opacity-40 disabled:cursor-not-allowed rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
전송
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</AuthGuard>
|
</AuthGuard>
|
||||||
|
|||||||
@@ -1,33 +1,81 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
import AuthGuard from "@/components/auth-guard";
|
import AuthGuard from "@/components/auth-guard";
|
||||||
import NavBar from "@/components/nav-bar";
|
import NavBar from "@/components/nav-bar";
|
||||||
|
import { useApi } from "@/lib/use-api";
|
||||||
|
|
||||||
|
interface DashData {
|
||||||
|
knowledgeCount: number;
|
||||||
|
dueCards: number;
|
||||||
|
activeTodos: number;
|
||||||
|
habitStreaks: number;
|
||||||
|
chatSessions: number;
|
||||||
|
tags: number;
|
||||||
|
}
|
||||||
|
|
||||||
export default function DashboardPage() {
|
export default function DashboardPage() {
|
||||||
|
const { request } = useApi();
|
||||||
|
const [data, setData] = useState<DashData | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
Promise.all([
|
||||||
|
request<unknown[]>({ method: "GET", url: "/api/knowledge" }).catch(() => []),
|
||||||
|
request<unknown[]>({ method: "GET", url: "/api/study-cards/due" }).catch(() => []),
|
||||||
|
request<unknown[]>({ method: "GET", url: "/api/todos?status=PENDING" }).catch(() => []),
|
||||||
|
request<{ STREAK_CURRENT?: number }[]>({ method: "GET", url: "/api/habits" }).catch(() => []),
|
||||||
|
request<unknown[]>({ method: "GET", url: "/api/chat/sessions" }).catch(() => []),
|
||||||
|
request<unknown[]>({ method: "GET", url: "/api/tags" }).catch(() => []),
|
||||||
|
]).then(([knowledge, dueCards, todos, habits, sessions, tags]) => {
|
||||||
|
const activeStreaks = (habits as { STREAK_CURRENT?: number }[]).filter(
|
||||||
|
(h) => h.STREAK_CURRENT && h.STREAK_CURRENT > 0
|
||||||
|
).length;
|
||||||
|
setData({
|
||||||
|
knowledgeCount: knowledge.length,
|
||||||
|
dueCards: dueCards.length,
|
||||||
|
activeTodos: todos.length,
|
||||||
|
habitStreaks: activeStreaks,
|
||||||
|
chatSessions: sessions.length,
|
||||||
|
tags: tags.length,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cards: { title: string; value: string; description: string; href: string; color: string }[] = data
|
||||||
|
? [
|
||||||
|
{ title: "Knowledge Items", value: String(data.knowledgeCount), description: "수집된 항목", href: "/knowledge", color: "text-blue-400" },
|
||||||
|
{ title: "Due Study Cards", value: String(data.dueCards), description: "복습 대기 카드", href: "/study", color: "text-purple-400" },
|
||||||
|
{ title: "Active Todos", value: String(data.activeTodos), description: "진행중인 할 일", href: "/todos", color: "text-yellow-400" },
|
||||||
|
{ title: "Habit Streaks", value: String(data.habitStreaks), description: "활성 연속 기록", href: "/habits", color: "text-green-400" },
|
||||||
|
{ title: "Chat Sessions", value: String(data.chatSessions), description: "대화 세션", href: "/chat", color: "text-cyan-400" },
|
||||||
|
{ title: "Tags", value: String(data.tags), description: "태그 수", href: "#", color: "text-indigo-400" },
|
||||||
|
]
|
||||||
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthGuard>
|
<AuthGuard>
|
||||||
<NavBar />
|
<NavBar />
|
||||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||||
<h1 className="text-2xl font-bold mb-6">Dashboard</h1>
|
<h1 className="text-2xl font-bold mb-6">Dashboard</h1>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
{!data ? (
|
||||||
<DashCard title="Knowledge Items" value="-" description="Total ingested items" />
|
<p className="text-[var(--color-text-muted)]">Loading...</p>
|
||||||
<DashCard title="Due Study Cards" value="-" description="Cards due for review" />
|
) : (
|
||||||
<DashCard title="Active Todos" value="-" description="Pending tasks" />
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
<DashCard title="Habit Streaks" value="-" description="Current active streaks" />
|
{cards.map((card) => (
|
||||||
<DashCard title="Chat Sessions" value="-" description="Active conversations" />
|
<Link
|
||||||
<DashCard title="Tags" value="-" description="Knowledge categories" />
|
key={card.title}
|
||||||
</div>
|
href={card.href}
|
||||||
|
className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)] hover:border-[var(--color-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
<h3 className="text-sm text-[var(--color-text-muted)] mb-1">{card.title}</h3>
|
||||||
|
<p className={`text-3xl font-bold mb-1 ${card.color}`}>{card.value}</p>
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)]">{card.description}</p>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
</AuthGuard>
|
</AuthGuard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DashCard({ title, value, description }: { title: string; value: string; description: string }) {
|
|
||||||
return (
|
|
||||||
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)]">
|
|
||||||
<h3 className="text-sm text-[var(--color-text-muted)] mb-1">{title}</h3>
|
|
||||||
<p className="text-3xl font-bold mb-1">{value}</p>
|
|
||||||
<p className="text-sm text-[var(--color-text-muted)]">{description}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,22 +1,203 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import AuthGuard from "@/components/auth-guard";
|
import AuthGuard from "@/components/auth-guard";
|
||||||
import NavBar from "@/components/nav-bar";
|
import NavBar from "@/components/nav-bar";
|
||||||
|
import { useApi } from "@/lib/use-api";
|
||||||
|
|
||||||
|
interface Habit {
|
||||||
|
ID: string;
|
||||||
|
NAME: string;
|
||||||
|
DESCRIPTION: string | null;
|
||||||
|
HABIT_TYPE: string;
|
||||||
|
COLOR: string;
|
||||||
|
STREAK_CURRENT: number;
|
||||||
|
STREAK_BEST: number;
|
||||||
|
CHECKED_TODAY: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLORS = ["#6366f1", "#ec4899", "#f59e0b", "#10b981", "#3b82f6", "#8b5cf6", "#ef4444"];
|
||||||
|
|
||||||
export default function HabitsPage() {
|
export default function HabitsPage() {
|
||||||
|
const { request } = useApi();
|
||||||
|
const [habits, setHabits] = useState<Habit[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Add form
|
||||||
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
|
const [newName, setNewName] = useState("");
|
||||||
|
const [newColor, setNewColor] = useState(COLORS[0]);
|
||||||
|
|
||||||
|
const fetchHabits = async () => {
|
||||||
|
try {
|
||||||
|
const data = await request<Habit[]>({ method: "GET", url: "/api/habits" });
|
||||||
|
setHabits(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load habits:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchHabits();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
if (!newName.trim()) return;
|
||||||
|
try {
|
||||||
|
await request({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/habits",
|
||||||
|
data: { name: newName.trim(), habitType: "DAILY", color: newColor },
|
||||||
|
});
|
||||||
|
setNewName("");
|
||||||
|
setShowAdd(false);
|
||||||
|
fetchHabits();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to create habit:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCheckin = async (habitId: string) => {
|
||||||
|
try {
|
||||||
|
await request({ method: "POST", url: `/api/habits/${habitId}/checkin`, data: {} });
|
||||||
|
fetchHabits();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to check in:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (habitId: string) => {
|
||||||
|
if (!confirm("이 습관을 삭제하시겠습니까?")) return;
|
||||||
|
try {
|
||||||
|
await request({ method: "DELETE", url: `/api/habits/${habitId}` });
|
||||||
|
fetchHabits();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete habit:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 오늘 요일 (월~일 한글)
|
||||||
|
const today = new Date();
|
||||||
|
const weekDays = ["일", "월", "화", "수", "목", "금", "토"];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthGuard>
|
<AuthGuard>
|
||||||
<NavBar />
|
<NavBar />
|
||||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
<main className="max-w-3xl mx-auto px-4 py-8">
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<h1 className="text-2xl font-bold">Habits</h1>
|
<div>
|
||||||
<button className="px-4 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors">
|
<h1 className="text-2xl font-bold">Habits</h1>
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)]">
|
||||||
|
{today.toLocaleDateString("ko-KR", { month: "long", day: "numeric", weekday: "long" })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAdd(!showAdd)}
|
||||||
|
className="px-4 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors text-sm"
|
||||||
|
>
|
||||||
+ Add Habit
|
+ Add Habit
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)]">
|
|
||||||
<p className="text-[var(--color-text-muted)]">No habits tracked yet. Start building good habits.</p>
|
{/* Add form */}
|
||||||
</div>
|
{showAdd && (
|
||||||
|
<div className="bg-[var(--color-bg-card)] rounded-xl p-4 border border-[var(--color-border)] mb-4 space-y-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
||||||
|
placeholder="습관 이름 (예: 물 2L 마시기)"
|
||||||
|
autoFocus
|
||||||
|
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm text-[var(--color-text-muted)]">색상:</span>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{COLORS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
onClick={() => setNewColor(c)}
|
||||||
|
className={`w-6 h-6 rounded-full transition-transform ${newColor === c ? "scale-125 ring-2 ring-white" : ""}`}
|
||||||
|
style={{ backgroundColor: c }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleAdd}
|
||||||
|
disabled={!newName.trim()}
|
||||||
|
className="ml-auto px-4 py-1.5 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] disabled:opacity-40 rounded-lg text-sm transition-colors"
|
||||||
|
>
|
||||||
|
추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Habit list */}
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-[var(--color-text-muted)]">Loading...</p>
|
||||||
|
) : habits.length === 0 ? (
|
||||||
|
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)]">
|
||||||
|
<p className="text-[var(--color-text-muted)]">아직 습관이 없습니다. 새로운 습관을 만들어보세요.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{habits.map((habit) => (
|
||||||
|
<div
|
||||||
|
key={habit.ID}
|
||||||
|
className="bg-[var(--color-bg-card)] rounded-xl p-4 border border-[var(--color-border)] hover:border-[var(--color-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Check-in button */}
|
||||||
|
<button
|
||||||
|
onClick={() => !habit.CHECKED_TODAY && handleCheckin(habit.ID)}
|
||||||
|
disabled={habit.CHECKED_TODAY}
|
||||||
|
className={`w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0 transition-all ${
|
||||||
|
habit.CHECKED_TODAY
|
||||||
|
? "opacity-90"
|
||||||
|
: "opacity-50 hover:opacity-100 hover:scale-105"
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: habit.COLOR || "#6366f1" }}
|
||||||
|
>
|
||||||
|
{habit.CHECKED_TODAY ? (
|
||||||
|
<svg className="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<span className="text-white text-lg font-bold">+</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-medium">{habit.NAME}</h3>
|
||||||
|
<div className="flex items-center gap-4 mt-1">
|
||||||
|
<span className="text-sm" style={{ color: habit.COLOR || "#6366f1" }}>
|
||||||
|
{habit.STREAK_CURRENT > 0 ? `${habit.STREAK_CURRENT}일 연속` : "시작 전"}
|
||||||
|
</span>
|
||||||
|
{habit.STREAK_BEST > 0 && (
|
||||||
|
<span className="text-xs text-[var(--color-text-muted)]">
|
||||||
|
최고: {habit.STREAK_BEST}일
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(habit.ID)}
|
||||||
|
className="text-xs text-red-400 hover:text-red-300 px-2"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
</AuthGuard>
|
</AuthGuard>
|
||||||
);
|
);
|
||||||
|
|||||||
436
sundol-frontend/src/app/knowledge/[id]/page.tsx
Normal file
436
sundol-frontend/src/app/knowledge/[id]/page.tsx
Normal file
@@ -0,0 +1,436 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useParams, useRouter } from "next/navigation";
|
||||||
|
import AuthGuard from "@/components/auth-guard";
|
||||||
|
import NavBar from "@/components/nav-bar";
|
||||||
|
import { useApi } from "@/lib/use-api";
|
||||||
|
import ReactMarkdown from "react-markdown";
|
||||||
|
|
||||||
|
interface Category {
|
||||||
|
ID: string;
|
||||||
|
NAME: string;
|
||||||
|
DEPTH: number;
|
||||||
|
FULL_PATH: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KnowledgeItem {
|
||||||
|
ID: string;
|
||||||
|
TYPE: string;
|
||||||
|
TITLE: string;
|
||||||
|
SOURCE_URL: string;
|
||||||
|
RAW_TEXT: string;
|
||||||
|
STRUCTURED_CONTENT: string | null;
|
||||||
|
STATUS: string;
|
||||||
|
CREATED_AT: string;
|
||||||
|
UPDATED_AT: string;
|
||||||
|
CATEGORIES: Category[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Chunk {
|
||||||
|
ID: string;
|
||||||
|
CHUNK_INDEX: number;
|
||||||
|
CONTENT: string;
|
||||||
|
TOKEN_COUNT: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
PENDING: "bg-yellow-500/20 text-yellow-400",
|
||||||
|
EXTRACTING: "bg-blue-500/20 text-blue-400",
|
||||||
|
CHUNKING: "bg-purple-500/20 text-purple-400",
|
||||||
|
CATEGORIZING: "bg-indigo-500/20 text-indigo-400",
|
||||||
|
STRUCTURING: "bg-orange-500/20 text-orange-400",
|
||||||
|
EMBEDDING: "bg-cyan-500/20 text-cyan-400",
|
||||||
|
READY: "bg-green-500/20 text-green-400",
|
||||||
|
FAILED: "bg-red-500/20 text-red-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeLabels: Record<string, string> = {
|
||||||
|
YOUTUBE: "YouTube",
|
||||||
|
WEB: "Web",
|
||||||
|
TEXT: "Text",
|
||||||
|
};
|
||||||
|
|
||||||
|
function extractYouTubeVideoId(url: string): string | null {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
if (u.hostname === "youtu.be") return u.pathname.slice(1);
|
||||||
|
if (u.hostname.includes("youtube.com")) return u.searchParams.get("v");
|
||||||
|
} catch {
|
||||||
|
// invalid URL
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function KnowledgeDetailPage() {
|
||||||
|
const { request } = useApi();
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams();
|
||||||
|
const id = params.id as string;
|
||||||
|
|
||||||
|
const [item, setItem] = useState<KnowledgeItem | null>(null);
|
||||||
|
const [chunks, setChunks] = useState<Chunk[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [editingTitle, setEditingTitle] = useState(false);
|
||||||
|
const [titleDraft, setTitleDraft] = useState("");
|
||||||
|
const [showChunks, setShowChunks] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [generating, setGenerating] = useState(false);
|
||||||
|
const [structuring, setStructuring] = useState(false);
|
||||||
|
const [showStructured, setShowStructured] = useState(true);
|
||||||
|
|
||||||
|
const fetchItem = async () => {
|
||||||
|
try {
|
||||||
|
const data = await request<KnowledgeItem>({ method: "GET", url: `/api/knowledge/${id}` });
|
||||||
|
setItem(data);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : "Failed to load";
|
||||||
|
setError(msg);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchChunks = async () => {
|
||||||
|
try {
|
||||||
|
const data = await request<Chunk[]>({ method: "GET", url: `/api/knowledge/${id}/chunks` });
|
||||||
|
setChunks(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load chunks:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchItem();
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
// Poll while processing
|
||||||
|
useEffect(() => {
|
||||||
|
if (!item) return;
|
||||||
|
const processing = ["PENDING", "EXTRACTING", "STRUCTURING", "CHUNKING", "CATEGORIZING", "EMBEDDING"].includes(item.STATUS);
|
||||||
|
if (!processing) return;
|
||||||
|
const interval = setInterval(fetchItem, 3000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [item?.STATUS]);
|
||||||
|
|
||||||
|
const handleSaveTitle = async () => {
|
||||||
|
if (!titleDraft.trim()) return;
|
||||||
|
try {
|
||||||
|
const updated = await request<KnowledgeItem>({
|
||||||
|
method: "PATCH",
|
||||||
|
url: `/api/knowledge/${id}`,
|
||||||
|
data: { title: titleDraft.trim() },
|
||||||
|
});
|
||||||
|
setItem(updated);
|
||||||
|
setEditingTitle(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update title:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!confirm("정말 삭제하시겠습니까?")) return;
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await request({ method: "DELETE", url: `/api/knowledge/${id}` });
|
||||||
|
router.push("/knowledge");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete:", err);
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleChunks = () => {
|
||||||
|
if (!showChunks && chunks.length === 0) {
|
||||||
|
fetchChunks();
|
||||||
|
}
|
||||||
|
setShowChunks(!showChunks);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<AuthGuard>
|
||||||
|
<NavBar />
|
||||||
|
<main className="max-w-4xl mx-auto px-4 py-8">
|
||||||
|
<p className="text-[var(--color-text-muted)]">Loading...</p>
|
||||||
|
</main>
|
||||||
|
</AuthGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !item) {
|
||||||
|
return (
|
||||||
|
<AuthGuard>
|
||||||
|
<NavBar />
|
||||||
|
<main className="max-w-4xl mx-auto px-4 py-8">
|
||||||
|
<p className="text-red-400">{error || "Item not found"}</p>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/knowledge")}
|
||||||
|
className="mt-4 text-sm text-[var(--color-primary)] hover:underline"
|
||||||
|
>
|
||||||
|
← Back to Knowledge
|
||||||
|
</button>
|
||||||
|
</main>
|
||||||
|
</AuthGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoId = item.TYPE === "YOUTUBE" && item.SOURCE_URL ? extractYouTubeVideoId(item.SOURCE_URL) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthGuard>
|
||||||
|
<NavBar />
|
||||||
|
<main className="max-w-4xl mx-auto px-4 py-8">
|
||||||
|
{/* Back link */}
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/knowledge")}
|
||||||
|
className="text-sm text-[var(--color-text-muted)] hover:text-[var(--color-primary)] mb-4 inline-block"
|
||||||
|
>
|
||||||
|
← Back to Knowledge
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)] mb-6">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded bg-[var(--color-bg-hover)] text-[var(--color-text-muted)]">
|
||||||
|
{typeLabels[item.TYPE] || item.TYPE}
|
||||||
|
</span>
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded ${statusColors[item.STATUS] || ""}`}>
|
||||||
|
{item.STATUS}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title (editable) */}
|
||||||
|
{editingTitle ? (
|
||||||
|
<div className="flex gap-2 mb-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={titleDraft}
|
||||||
|
onChange={(e) => setTitleDraft(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleSaveTitle()}
|
||||||
|
className="flex-1 px-3 py-1 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none text-lg font-bold"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveTitle}
|
||||||
|
className="px-3 py-1 text-sm bg-[var(--color-primary)] rounded-lg"
|
||||||
|
>
|
||||||
|
저장
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingTitle(false)}
|
||||||
|
className="px-3 py-1 text-sm bg-[var(--color-bg-hover)] border border-[var(--color-border)] rounded-lg"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<h1
|
||||||
|
className="text-xl font-bold mb-3 cursor-pointer hover:text-[var(--color-primary)] transition-colors"
|
||||||
|
onClick={() => {
|
||||||
|
setTitleDraft(item.TITLE || "");
|
||||||
|
setEditingTitle(true);
|
||||||
|
}}
|
||||||
|
title="클릭하여 제목 수정"
|
||||||
|
>
|
||||||
|
{item.TITLE || "Untitled"}
|
||||||
|
</h1>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Source URL */}
|
||||||
|
{item.SOURCE_URL && (
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)] mb-3 break-all">
|
||||||
|
<a href={item.SOURCE_URL} target="_blank" rel="noopener noreferrer" className="hover:text-[var(--color-primary)]">
|
||||||
|
{item.SOURCE_URL}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Meta */}
|
||||||
|
<div className="flex gap-4 text-xs text-[var(--color-text-muted)]">
|
||||||
|
<span>생성: {new Date(item.CREATED_AT).toLocaleString("ko-KR")}</span>
|
||||||
|
<span>수정: {new Date(item.UPDATED_AT).toLocaleString("ko-KR")}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* YouTube Embed */}
|
||||||
|
{videoId && (
|
||||||
|
<div className="rounded-xl overflow-hidden border border-[var(--color-border)] mb-6">
|
||||||
|
<div className="relative w-full" style={{ paddingBottom: "56.25%" }}>
|
||||||
|
<iframe
|
||||||
|
className="absolute inset-0 w-full h-full"
|
||||||
|
src={`https://www.youtube.com/embed/${videoId}`}
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Processing indicator */}
|
||||||
|
{/* Categories */}
|
||||||
|
{item.CATEGORIES && item.CATEGORIES.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-2 mb-6">
|
||||||
|
{item.CATEGORIES.map((cat) => (
|
||||||
|
<span
|
||||||
|
key={cat.ID}
|
||||||
|
className="text-xs px-2.5 py-1 rounded-full bg-[var(--color-primary)]/15 text-[var(--color-primary)] border border-[var(--color-primary)]/30"
|
||||||
|
>
|
||||||
|
{cat.FULL_PATH}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{["PENDING", "EXTRACTING", "STRUCTURING", "CHUNKING", "CATEGORIZING", "EMBEDDING"].includes(item.STATUS) && (
|
||||||
|
<div className="bg-blue-500/10 border border-blue-500/30 rounded-xl p-4 mb-6 flex items-center gap-3">
|
||||||
|
<div className="w-4 h-4 border-2 border-blue-400 border-t-transparent rounded-full animate-spin" />
|
||||||
|
<span className="text-sm text-blue-400">
|
||||||
|
{item.STATUS === "PENDING" && "파이프라인 대기 중..."}
|
||||||
|
{item.STATUS === "EXTRACTING" && "텍스트 추출 중..."}
|
||||||
|
{item.STATUS === "STRUCTURING" && "내용 정리 중..."}
|
||||||
|
{item.STATUS === "CHUNKING" && "청킹 처리 중..."}
|
||||||
|
{item.STATUS === "CATEGORIZING" && "카테고리 분류 중..."}
|
||||||
|
{item.STATUS === "EMBEDDING" && "벡터 임베딩 중..."}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Structured Content */}
|
||||||
|
{item.STRUCTURED_CONTENT && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowStructured(!showStructured)}
|
||||||
|
className="text-sm text-[var(--color-primary)] hover:underline mb-3"
|
||||||
|
>
|
||||||
|
{showStructured ? "▼ 정리된 내용 숨기기" : "▶ 정리된 내용 보기"}
|
||||||
|
</button>
|
||||||
|
{showStructured && (
|
||||||
|
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)] max-w-none">
|
||||||
|
<div className="structured-content text-sm leading-relaxed">
|
||||||
|
<ReactMarkdown
|
||||||
|
components={{
|
||||||
|
h1: ({children}) => <h1 className="text-xl font-bold mt-6 mb-3">{children}</h1>,
|
||||||
|
h2: ({children}) => <h2 className="text-lg font-bold mt-5 mb-2">{children}</h2>,
|
||||||
|
h3: ({children}) => <h3 className="text-base font-bold mt-4 mb-2">{children}</h3>,
|
||||||
|
p: ({children}) => <p className="mb-3">{children}</p>,
|
||||||
|
ul: ({children}) => <ul className="list-disc ml-5 mb-3 space-y-1">{children}</ul>,
|
||||||
|
ol: ({children}) => <ol className="list-decimal ml-5 mb-3 space-y-1">{children}</ol>,
|
||||||
|
li: ({children}) => <li className="leading-relaxed">{children}</li>,
|
||||||
|
strong: ({children}) => <strong className="font-bold">{children}</strong>,
|
||||||
|
blockquote: ({children}) => <blockquote className="border-l-2 border-[var(--color-primary)] pl-4 my-3 italic text-[var(--color-text-muted)]">{children}</blockquote>,
|
||||||
|
code: ({children}) => <code className="bg-[var(--color-bg-hover)] px-1.5 py-0.5 rounded text-xs">{children}</code>,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.STRUCTURED_CONTENT}
|
||||||
|
</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Chunks toggle */}
|
||||||
|
{item.STATUS === "READY" && (
|
||||||
|
<div className="mb-6">
|
||||||
|
<button
|
||||||
|
onClick={handleToggleChunks}
|
||||||
|
className="text-sm text-[var(--color-primary)] hover:underline"
|
||||||
|
>
|
||||||
|
{showChunks ? "▼ 청크 숨기기" : "▶ 청크 보기"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showChunks && (
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
{chunks.length === 0 ? (
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)]">Loading chunks...</p>
|
||||||
|
) : (
|
||||||
|
chunks.map((chunk) => (
|
||||||
|
<div
|
||||||
|
key={chunk.ID}
|
||||||
|
className="bg-[var(--color-bg-card)] rounded-lg p-4 border border-[var(--color-border)]"
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<span className="text-xs text-[var(--color-text-muted)]">
|
||||||
|
Chunk #{chunk.CHUNK_INDEX}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-[var(--color-text-muted)]">
|
||||||
|
~{chunk.TOKEN_COUNT} tokens
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm whitespace-pre-wrap">{chunk.CONTENT}</p>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="pt-4 border-t border-[var(--color-border)] flex items-center gap-4">
|
||||||
|
{item.STATUS === "READY" && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
setStructuring(true);
|
||||||
|
setShowStructured(true);
|
||||||
|
try {
|
||||||
|
// 비동기 요청: 서버가 STRUCTURING 상태로 변경 후 백그라운드 처리
|
||||||
|
// 폴링이 자동으로 진행 상태를 갱신함
|
||||||
|
await request({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/knowledge/${id}/structure`,
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
// 완료 후 최종 상태 갱신
|
||||||
|
await fetchItem();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to structure:", err);
|
||||||
|
alert("내용 정리에 실패했습니다.");
|
||||||
|
await fetchItem();
|
||||||
|
} finally {
|
||||||
|
setStructuring(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={structuring}
|
||||||
|
className="text-sm text-[var(--color-primary)] hover:underline disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{structuring ? "정리 중..." : item.STRUCTURED_CONTENT ? "다시 정리하기" : "내용 정리하기"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
setGenerating(true);
|
||||||
|
try {
|
||||||
|
const result = await request<{ generated: number }>({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/study-cards/generate/${id}`,
|
||||||
|
});
|
||||||
|
alert(`${result.generated}개의 스터디 카드가 생성되었습니다.`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to generate cards:", err);
|
||||||
|
alert("카드 생성에 실패했습니다.");
|
||||||
|
} finally {
|
||||||
|
setGenerating(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={generating}
|
||||||
|
className="text-sm text-[var(--color-primary)] hover:underline disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{generating ? "카드 생성 중..." : "스터디 카드 생성"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
className="text-sm text-red-400 hover:text-red-300 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{deleting ? "삭제 중..." : "삭제"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</AuthGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
276
sundol-frontend/src/app/knowledge/add/page.tsx
Normal file
276
sundol-frontend/src/app/knowledge/add/page.tsx
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useMemo, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import AuthGuard from "@/components/auth-guard";
|
||||||
|
import NavBar from "@/components/nav-bar";
|
||||||
|
import { useApi } from "@/lib/use-api";
|
||||||
|
|
||||||
|
type KnowledgeType = "TEXT" | "WEB" | "YOUTUBE";
|
||||||
|
|
||||||
|
interface ModelInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
vendor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractYouTubeVideoId(url: string): string | null {
|
||||||
|
try {
|
||||||
|
const u = new URL(url);
|
||||||
|
if (u.hostname === "youtu.be") return u.pathname.slice(1);
|
||||||
|
if (u.hostname.includes("youtube.com")) return u.searchParams.get("v");
|
||||||
|
} catch {
|
||||||
|
// invalid URL
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function KnowledgeAddPage() {
|
||||||
|
const { request } = useApi();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [type, setType] = useState<KnowledgeType>("TEXT");
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [url, setUrl] = useState("");
|
||||||
|
const [rawText, setRawText] = useState("");
|
||||||
|
const [modelId, setModelId] = useState("");
|
||||||
|
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [fetchingTranscript, setFetchingTranscript] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
request<{ models: ModelInfo[]; defaultModel: string; configured: boolean }>({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/models",
|
||||||
|
}).then((data) => {
|
||||||
|
setModels(data.models);
|
||||||
|
setModelId(data.defaultModel);
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error("Failed to load models:", err);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const videoId = useMemo(() => (type === "YOUTUBE" ? extractYouTubeVideoId(url) : null), [type, url]);
|
||||||
|
|
||||||
|
const canFetchTranscript = type === "YOUTUBE" && videoId !== null && !fetchingTranscript;
|
||||||
|
|
||||||
|
const handleFetchTranscript = async () => {
|
||||||
|
if (!canFetchTranscript) return;
|
||||||
|
setError(null);
|
||||||
|
setFetchingTranscript(true);
|
||||||
|
try {
|
||||||
|
const data = await request<{ transcript?: string; error?: string }>({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/knowledge/youtube-transcript?url=${encodeURIComponent(url.trim())}`,
|
||||||
|
});
|
||||||
|
if (data.error) {
|
||||||
|
setError(data.error);
|
||||||
|
} else if (data.transcript) {
|
||||||
|
setRawText(data.transcript);
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : "트랜스크립트를 가져올 수 없습니다";
|
||||||
|
setError(msg);
|
||||||
|
} finally {
|
||||||
|
setFetchingTranscript(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const canSubmit =
|
||||||
|
!submitting &&
|
||||||
|
((type === "TEXT" && rawText.trim().length > 0) ||
|
||||||
|
(type === "WEB" && url.trim().length > 0) ||
|
||||||
|
(type === "YOUTUBE" && url.trim().length > 0 && rawText.trim().length > 0));
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setError(null);
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await request({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/knowledge/ingest",
|
||||||
|
data: {
|
||||||
|
type,
|
||||||
|
title: title.trim() || null,
|
||||||
|
url: type !== "TEXT" ? url.trim() : null,
|
||||||
|
rawText: type !== "WEB" ? rawText.trim() : null,
|
||||||
|
modelId: modelId || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
router.push("/knowledge");
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : "Failed to submit";
|
||||||
|
setError(msg);
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const types: { value: KnowledgeType; label: string }[] = [
|
||||||
|
{ value: "TEXT", label: "Text" },
|
||||||
|
{ value: "WEB", label: "Web" },
|
||||||
|
{ value: "YOUTUBE", label: "YouTube" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 벤더별 그룹화
|
||||||
|
const groupedModels = useMemo(() => {
|
||||||
|
const groups: Record<string, ModelInfo[]> = {};
|
||||||
|
for (const m of models) {
|
||||||
|
if (!groups[m.vendor]) groups[m.vendor] = [];
|
||||||
|
groups[m.vendor].push(m);
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}, [models]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthGuard>
|
||||||
|
<NavBar />
|
||||||
|
<main className="max-w-3xl mx-auto px-4 py-8">
|
||||||
|
<h1 className="text-2xl font-bold mb-6">Add Knowledge</h1>
|
||||||
|
|
||||||
|
{/* Type Tabs */}
|
||||||
|
<div className="flex gap-2 mb-6">
|
||||||
|
{types.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.value}
|
||||||
|
onClick={() => {
|
||||||
|
setType(t.value);
|
||||||
|
setUrl("");
|
||||||
|
setRawText("");
|
||||||
|
setError(null);
|
||||||
|
}}
|
||||||
|
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
type === t.value
|
||||||
|
? "bg-[var(--color-primary)] text-white"
|
||||||
|
: "bg-[var(--color-bg-card)] text-[var(--color-text-muted)] border border-[var(--color-border)] hover:border-[var(--color-primary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Title */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-[var(--color-text-muted)] mb-1">
|
||||||
|
제목 (비워두면 AI가 자동 생성)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="비워두면 내용 기반으로 자동 생성"
|
||||||
|
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-card)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* URL (WEB / YOUTUBE) */}
|
||||||
|
{type !== "TEXT" && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-[var(--color-text-muted)] mb-1">URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
placeholder={type === "YOUTUBE" ? "https://www.youtube.com/watch?v=..." : "https://example.com/article"}
|
||||||
|
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-card)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* YouTube Embed */}
|
||||||
|
{type === "YOUTUBE" && videoId && (
|
||||||
|
<div className="rounded-lg overflow-hidden border border-[var(--color-border)]">
|
||||||
|
<div className="relative w-full" style={{ paddingBottom: "56.25%" }}>
|
||||||
|
<iframe
|
||||||
|
className="absolute inset-0 w-full h-full"
|
||||||
|
src={`https://www.youtube.com/embed/${videoId}`}
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
allowFullScreen
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Fetch Transcript Button (YOUTUBE) */}
|
||||||
|
{type === "YOUTUBE" && videoId && (
|
||||||
|
<button
|
||||||
|
onClick={handleFetchTranscript}
|
||||||
|
disabled={!canFetchTranscript}
|
||||||
|
className="w-full px-4 py-2 bg-[var(--color-bg-card)] border border-[var(--color-border)] hover:border-[var(--color-primary)] disabled:opacity-40 disabled:cursor-not-allowed rounded-lg transition-colors text-sm font-medium"
|
||||||
|
>
|
||||||
|
{fetchingTranscript ? "트랜스크립트 가져오는 중..." : "트랜스크립트 자동 가져오기"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Text Input (TEXT / YOUTUBE) */}
|
||||||
|
{type !== "WEB" && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-[var(--color-text-muted)] mb-1">
|
||||||
|
{type === "YOUTUBE" ? "Transcript / 내용 붙여넣기" : "텍스트 입력"}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={rawText}
|
||||||
|
onChange={(e) => setRawText(e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
type === "YOUTUBE"
|
||||||
|
? "영상의 transcript나 내용을 여기에 붙여넣으세요..."
|
||||||
|
: "텍스트를 직접 입력하세요..."
|
||||||
|
}
|
||||||
|
rows={12}
|
||||||
|
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-card)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none resize-y"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Model Selection */}
|
||||||
|
{models.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-[var(--color-text-muted)] mb-1">
|
||||||
|
AI 모델
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={modelId}
|
||||||
|
onChange={(e) => setModelId(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-card)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none"
|
||||||
|
>
|
||||||
|
{Object.entries(groupedModels).map(([vendor, vendorModels]) => (
|
||||||
|
<optgroup key={vendor} label={vendor}>
|
||||||
|
{vendorModels.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-400">{error}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!canSubmit}
|
||||||
|
className="px-6 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] disabled:opacity-40 disabled:cursor-not-allowed rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
{submitting ? "처리 중..." : "추가"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => router.push("/knowledge")}
|
||||||
|
className="px-6 py-2 bg-[var(--color-bg-card)] border border-[var(--color-border)] hover:border-[var(--color-primary)] rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</AuthGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,23 +1,475 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
import AuthGuard from "@/components/auth-guard";
|
import AuthGuard from "@/components/auth-guard";
|
||||||
import NavBar from "@/components/nav-bar";
|
import NavBar from "@/components/nav-bar";
|
||||||
|
import { useApi } from "@/lib/use-api";
|
||||||
|
|
||||||
|
interface KnowledgeItem {
|
||||||
|
ID: string;
|
||||||
|
TYPE: string;
|
||||||
|
TITLE: string;
|
||||||
|
SOURCE_URL: string;
|
||||||
|
STATUS: string;
|
||||||
|
CREATED_AT: string;
|
||||||
|
CATEGORIES?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Category {
|
||||||
|
ID: string;
|
||||||
|
NAME: string;
|
||||||
|
PARENT_ID: string | null;
|
||||||
|
DEPTH: number;
|
||||||
|
FULL_PATH: string;
|
||||||
|
ITEM_COUNT: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CategoryTree extends Category {
|
||||||
|
children: CategoryTree[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
PENDING: "bg-yellow-500/20 text-yellow-400",
|
||||||
|
EXTRACTING: "bg-blue-500/20 text-blue-400",
|
||||||
|
STRUCTURING: "bg-orange-500/20 text-orange-400",
|
||||||
|
CHUNKING: "bg-purple-500/20 text-purple-400",
|
||||||
|
CATEGORIZING: "bg-indigo-500/20 text-indigo-400",
|
||||||
|
EMBEDDING: "bg-cyan-500/20 text-cyan-400",
|
||||||
|
READY: "bg-green-500/20 text-green-400",
|
||||||
|
FAILED: "bg-red-500/20 text-red-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeLabels: Record<string, string> = {
|
||||||
|
YOUTUBE: "YouTube",
|
||||||
|
WEB: "Web",
|
||||||
|
TEXT: "Text",
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildTree(categories: Category[]): CategoryTree[] {
|
||||||
|
const map = new Map<string, CategoryTree>();
|
||||||
|
const roots: CategoryTree[] = [];
|
||||||
|
|
||||||
|
for (const cat of categories) {
|
||||||
|
map.set(cat.ID, { ...cat, children: [] });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const cat of categories) {
|
||||||
|
const node = map.get(cat.ID)!;
|
||||||
|
if (cat.PARENT_ID && map.has(cat.PARENT_ID)) {
|
||||||
|
map.get(cat.PARENT_ID)!.children.push(node);
|
||||||
|
} else {
|
||||||
|
roots.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 항목이 0개인 카테고리 제거
|
||||||
|
function prune(nodes: CategoryTree[]): CategoryTree[] {
|
||||||
|
return nodes
|
||||||
|
.map((n) => ({ ...n, children: prune(n.children) }))
|
||||||
|
.filter((n) => n.ITEM_COUNT > 0 || n.children.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return prune(roots);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ITEM_COUNT는 백엔드에서 하위 카테고리 포함 고유 항목 수를 이미 계산함
|
||||||
|
|
||||||
|
function CategoryNode({
|
||||||
|
node,
|
||||||
|
selectedId,
|
||||||
|
onSelect,
|
||||||
|
expanded,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
node: CategoryTree;
|
||||||
|
selectedId: string | null;
|
||||||
|
onSelect: (id: string, path: string) => void;
|
||||||
|
expanded: Set<string>;
|
||||||
|
onToggle: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const isExpanded = expanded.has(node.ID);
|
||||||
|
const isSelected = selectedId === node.ID;
|
||||||
|
const hasChildren = node.children.length > 0;
|
||||||
|
const totalCount = node.ITEM_COUNT;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-1 px-2 py-1.5 rounded-lg cursor-pointer text-sm transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? "bg-[var(--color-primary)]/20 text-[var(--color-primary)]"
|
||||||
|
: "hover:bg-[var(--color-bg-hover)] text-[var(--color-text-muted)]"
|
||||||
|
}`}
|
||||||
|
style={{ paddingLeft: `${(node.DEPTH - 1) * 16 + 8}px` }}
|
||||||
|
onClick={() => {
|
||||||
|
onSelect(node.ID, node.FULL_PATH);
|
||||||
|
if (hasChildren) onToggle(node.ID);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="w-4 text-center text-xs">
|
||||||
|
{hasChildren ? (isExpanded ? "▼" : "▶") : "·"}
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 truncate">{node.NAME}</span>
|
||||||
|
{totalCount > 0 && (
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded-full bg-[var(--color-bg-hover)]">
|
||||||
|
{totalCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isExpanded && hasChildren && (
|
||||||
|
<div>
|
||||||
|
{node.children.map((child) => (
|
||||||
|
<CategoryNode
|
||||||
|
key={child.ID}
|
||||||
|
node={child}
|
||||||
|
selectedId={selectedId}
|
||||||
|
onSelect={onSelect}
|
||||||
|
expanded={expanded}
|
||||||
|
onToggle={onToggle}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function KnowledgePage() {
|
export default function KnowledgePage() {
|
||||||
|
const { request } = useApi();
|
||||||
|
const [items, setItems] = useState<KnowledgeItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [viewMode, setViewMode] = useState<"list" | "category">("category");
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
// 카테고리 뷰 상태
|
||||||
|
const [categoryTree, setCategoryTree] = useState<CategoryTree[]>([]);
|
||||||
|
const [uncategorized, setUncategorized] = useState<KnowledgeItem[]>([]);
|
||||||
|
const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>("__all__");
|
||||||
|
const [selectedCategoryPath, setSelectedCategoryPath] = useState<string>("전체");
|
||||||
|
const [categoryItems, setCategoryItems] = useState<KnowledgeItem[]>([]);
|
||||||
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
|
const [catLoading, setCatLoading] = useState(false);
|
||||||
|
|
||||||
|
const fetchItems = async () => {
|
||||||
|
try {
|
||||||
|
const data = await request<KnowledgeItem[]>({ method: "GET", url: "/api/knowledge" });
|
||||||
|
setItems(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load knowledge items:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchCategories = async () => {
|
||||||
|
try {
|
||||||
|
const data = await request<{ categories: Category[]; uncategorized: KnowledgeItem[] }>({
|
||||||
|
method: "GET",
|
||||||
|
url: "/api/knowledge/categories",
|
||||||
|
});
|
||||||
|
const tree = buildTree(data.categories);
|
||||||
|
setCategoryTree(tree);
|
||||||
|
setUncategorized(data.uncategorized);
|
||||||
|
// 최상위 카테고리를 펼침
|
||||||
|
setExpanded(new Set(tree.map((n) => n.ID)));
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load categories:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchCategoryItems = async (categoryId: string) => {
|
||||||
|
setCatLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await request<KnowledgeItem[]>({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/knowledge/categories/${categoryId}/items`,
|
||||||
|
});
|
||||||
|
setCategoryItems(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load category items:", err);
|
||||||
|
} finally {
|
||||||
|
setCatLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectCategory = (id: string, path: string) => {
|
||||||
|
setSelectedCategoryId(id);
|
||||||
|
setSelectedCategoryPath(path);
|
||||||
|
setCurrentPage(1);
|
||||||
|
fetchCategoryItems(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectUncategorized = () => {
|
||||||
|
setSelectedCategoryId("__uncategorized__");
|
||||||
|
setSelectedCategoryPath("미분류");
|
||||||
|
setCategoryItems(uncategorized as KnowledgeItem[]);
|
||||||
|
setCurrentPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectAll = () => {
|
||||||
|
setSelectedCategoryId("__all__");
|
||||||
|
setSelectedCategoryPath("전체");
|
||||||
|
setCategoryItems(items);
|
||||||
|
setCurrentPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggle = (id: string) => {
|
||||||
|
setExpanded((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchItems();
|
||||||
|
fetchCategories();
|
||||||
|
const interval = setInterval(fetchItems, 5000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 카테고리 뷰에서 표시할 항목 결정
|
||||||
|
const allDisplayItems =
|
||||||
|
viewMode === "list"
|
||||||
|
? items
|
||||||
|
: selectedCategoryId === "__all__"
|
||||||
|
? items
|
||||||
|
: selectedCategoryId === "__uncategorized__"
|
||||||
|
? uncategorized
|
||||||
|
: categoryItems;
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(allDisplayItems.length / PAGE_SIZE));
|
||||||
|
const displayItems = allDisplayItems.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthGuard>
|
<AuthGuard>
|
||||||
<NavBar />
|
<NavBar />
|
||||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
<main className="max-w-7xl mx-auto px-4 py-8">
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<h1 className="text-2xl font-bold">Knowledge</h1>
|
<div className="flex items-center gap-4">
|
||||||
<button className="px-4 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors">
|
<h1 className="text-2xl font-bold">Knowledge</h1>
|
||||||
|
<div className="flex gap-1 bg-[var(--color-bg-card)] rounded-lg p-0.5 border border-[var(--color-border)]">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode("list")}
|
||||||
|
className={`px-3 py-1 text-xs rounded-md transition-colors ${
|
||||||
|
viewMode === "list"
|
||||||
|
? "bg-[var(--color-primary)] text-white"
|
||||||
|
: "text-[var(--color-text-muted)] hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
목록
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setViewMode("category"); fetchCategories(); }}
|
||||||
|
className={`px-3 py-1 text-xs rounded-md transition-colors ${
|
||||||
|
viewMode === "category"
|
||||||
|
? "bg-[var(--color-primary)] text-white"
|
||||||
|
: "text-[var(--color-text-muted)] hover:text-white"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
카테고리
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/knowledge/add"
|
||||||
|
className="px-4 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors"
|
||||||
|
>
|
||||||
+ Add Knowledge
|
+ Add Knowledge
|
||||||
</button>
|
</Link>
|
||||||
</div>
|
|
||||||
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)]">
|
|
||||||
<p className="text-[var(--color-text-muted)]">No knowledge items yet. Add your first item to get started.</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-[var(--color-text-muted)]">Loading...</p>
|
||||||
|
) : viewMode === "list" ? (
|
||||||
|
/* 목록 뷰 */
|
||||||
|
items.length === 0 ? (
|
||||||
|
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)]">
|
||||||
|
<p className="text-[var(--color-text-muted)]">No knowledge items yet. Add your first item to get started.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{displayItems.map((item) => (
|
||||||
|
<KnowledgeItemCard key={item.ID} item={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{totalPages > 1 && <Pagination currentPage={currentPage} totalPages={totalPages} onPageChange={setCurrentPage} />}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
/* 카테고리 뷰 — 2단 레이아웃 */
|
||||||
|
<div className="flex gap-4" style={{ minHeight: "60vh" }}>
|
||||||
|
{/* 왼쪽: 카테고리 트리 */}
|
||||||
|
<div className="w-64 shrink-0 bg-[var(--color-bg-card)] rounded-xl border border-[var(--color-border)] p-3 overflow-y-auto" style={{ maxHeight: "75vh" }}>
|
||||||
|
{/* 전체 보기 */}
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-1 px-2 py-1.5 rounded-lg cursor-pointer text-sm transition-colors mb-1 ${
|
||||||
|
selectedCategoryId === "__all__"
|
||||||
|
? "bg-[var(--color-primary)]/20 text-[var(--color-primary)]"
|
||||||
|
: "hover:bg-[var(--color-bg-hover)] text-[var(--color-text-muted)]"
|
||||||
|
}`}
|
||||||
|
onClick={handleSelectAll}
|
||||||
|
>
|
||||||
|
<span className="w-4 text-center text-xs">*</span>
|
||||||
|
<span className="flex-1">전체</span>
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded-full bg-[var(--color-bg-hover)]">{items.length}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 카테고리 트리 */}
|
||||||
|
{categoryTree.map((node) => (
|
||||||
|
<CategoryNode
|
||||||
|
key={node.ID}
|
||||||
|
node={node}
|
||||||
|
selectedId={selectedCategoryId}
|
||||||
|
onSelect={handleSelectCategory}
|
||||||
|
expanded={expanded}
|
||||||
|
onToggle={handleToggle}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* 미분류 */}
|
||||||
|
{uncategorized.length > 0 && (
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-1 px-2 py-1.5 rounded-lg cursor-pointer text-sm transition-colors mt-2 ${
|
||||||
|
selectedCategoryId === "__uncategorized__"
|
||||||
|
? "bg-[var(--color-primary)]/20 text-[var(--color-primary)]"
|
||||||
|
: "hover:bg-[var(--color-bg-hover)] text-[var(--color-text-muted)]"
|
||||||
|
}`}
|
||||||
|
onClick={handleSelectUncategorized}
|
||||||
|
>
|
||||||
|
<span className="w-4 text-center text-xs">?</span>
|
||||||
|
<span className="flex-1">미분류</span>
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded-full bg-[var(--color-bg-hover)]">{uncategorized.length}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 오른쪽: 선택된 카테고리의 항목 목록 */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{/* 브레드크럼 */}
|
||||||
|
<div className="text-sm text-[var(--color-text-muted)] mb-3">
|
||||||
|
{selectedCategoryPath.split("/").map((part, i, arr) => (
|
||||||
|
<span key={i}>
|
||||||
|
{i > 0 && <span className="mx-1">{">"}</span>}
|
||||||
|
<span className={i === arr.length - 1 ? "text-white" : ""}>{part}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{catLoading ? (
|
||||||
|
<p className="text-[var(--color-text-muted)]">Loading...</p>
|
||||||
|
) : (displayItems as KnowledgeItem[]).length === 0 ? (
|
||||||
|
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)]">
|
||||||
|
<p className="text-[var(--color-text-muted)]">
|
||||||
|
{selectedCategoryId === "__all__"
|
||||||
|
? "등록된 지식이 없습니다."
|
||||||
|
: "이 카테고리에 항목이 없습니다."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{(displayItems as KnowledgeItem[]).map((item) => (
|
||||||
|
<KnowledgeItemCard key={item.ID} item={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{totalPages > 1 && <Pagination currentPage={currentPage} totalPages={totalPages} onPageChange={setCurrentPage} />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
</AuthGuard>
|
</AuthGuard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function KnowledgeItemCard({ item }: { item: KnowledgeItem }) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
href={`/knowledge/${item.ID}`}
|
||||||
|
className="block bg-[var(--color-bg-card)] rounded-xl p-4 border border-[var(--color-border)] hover:border-[var(--color-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded bg-[var(--color-bg-hover)] text-[var(--color-text-muted)]">
|
||||||
|
{typeLabels[item.TYPE] || item.TYPE}
|
||||||
|
</span>
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded ${statusColors[item.STATUS] || ""}`}>
|
||||||
|
{item.STATUS}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="font-medium truncate">
|
||||||
|
{item.TITLE || item.SOURCE_URL || "Untitled"}
|
||||||
|
</h3>
|
||||||
|
{item.CATEGORIES && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
|
{item.CATEGORIES.split(", ").map((cat, i) => (
|
||||||
|
<span key={i} className="text-xs px-1.5 py-0.5 rounded bg-[var(--color-primary)]/10 text-[var(--color-primary)]">
|
||||||
|
{cat}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-[var(--color-text-muted)] ml-4 whitespace-nowrap">
|
||||||
|
{new Date(item.CREATED_AT).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pagination({ currentPage, totalPages, onPageChange }: { currentPage: number; totalPages: number; onPageChange: (page: number) => void }) {
|
||||||
|
const pages: number[] = [];
|
||||||
|
const start = Math.max(1, currentPage - 2);
|
||||||
|
const end = Math.min(totalPages, currentPage + 2);
|
||||||
|
for (let i = start; i <= end; i++) pages.push(i);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center gap-1 mt-6">
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(currentPage - 1)}
|
||||||
|
disabled={currentPage <= 1}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-lg bg-[var(--color-bg-card)] border border-[var(--color-border)] disabled:opacity-30 hover:bg-[var(--color-bg-hover)]"
|
||||||
|
>
|
||||||
|
이전
|
||||||
|
</button>
|
||||||
|
{start > 1 && (
|
||||||
|
<>
|
||||||
|
<button onClick={() => onPageChange(1)} className="px-3 py-1.5 text-sm rounded-lg hover:bg-[var(--color-bg-hover)]">1</button>
|
||||||
|
{start > 2 && <span className="px-1 text-[var(--color-text-muted)]">...</span>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{pages.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => onPageChange(p)}
|
||||||
|
className={`px-3 py-1.5 text-sm rounded-lg ${
|
||||||
|
p === currentPage ? "bg-[var(--color-primary)] text-white" : "hover:bg-[var(--color-bg-hover)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{end < totalPages && (
|
||||||
|
<>
|
||||||
|
{end < totalPages - 1 && <span className="px-1 text-[var(--color-text-muted)]">...</span>}
|
||||||
|
<button onClick={() => onPageChange(totalPages)} className="px-3 py-1.5 text-sm rounded-lg hover:bg-[var(--color-bg-hover)]">{totalPages}</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(currentPage + 1)}
|
||||||
|
disabled={currentPage >= totalPages}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-lg bg-[var(--color-bg-card)] border border-[var(--color-border)] disabled:opacity-30 hover:bg-[var(--color-bg-hover)]"
|
||||||
|
>
|
||||||
|
다음
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
64
sundol-frontend/src/app/login/callback/page.tsx
Normal file
64
sundol-frontend/src/app/login/callback/page.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { useAuth } from "@/lib/auth-context";
|
||||||
|
|
||||||
|
function CallbackHandler() {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const { login } = useAuth();
|
||||||
|
const processed = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (processed.current) return;
|
||||||
|
processed.current = true;
|
||||||
|
|
||||||
|
const code = searchParams.get("code");
|
||||||
|
const error = searchParams.get("error");
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error("OAuth error:", error);
|
||||||
|
router.replace("/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
router.replace("/login");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
api.post("/api/auth/google", { code })
|
||||||
|
.then((res) => {
|
||||||
|
login(res.data);
|
||||||
|
router.replace("/dashboard");
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("Login failed:", err);
|
||||||
|
router.replace("/login");
|
||||||
|
});
|
||||||
|
}, [searchParams, login, router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-4"></div>
|
||||||
|
<p className="text-[var(--color-text-muted)]">Signing in...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CallbackPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<p className="text-[var(--color-text-muted)]">Loading...</p>
|
||||||
|
</div>
|
||||||
|
}>
|
||||||
|
<CallbackHandler />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,25 +1,32 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useAuth } from "@/lib/auth-context";
|
import { useAuth } from "@/lib/auth-context";
|
||||||
|
|
||||||
|
const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "";
|
||||||
|
const REDIRECT_URI = `${typeof window !== "undefined" ? window.location.origin : ""}/login/callback`;
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { login } = useAuth();
|
const { isAuthenticated, isLoading } = useAuth();
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const handleGoogleLogin = async () => {
|
useEffect(() => {
|
||||||
setIsLoading(true);
|
if (!isLoading && isAuthenticated) {
|
||||||
try {
|
router.replace("/dashboard");
|
||||||
// TODO: Implement Google OAuth flow
|
|
||||||
// For now, placeholder
|
|
||||||
alert("Google OAuth not configured yet");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Login failed:", error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
}
|
||||||
|
}, [isAuthenticated, isLoading, router]);
|
||||||
|
|
||||||
|
const handleGoogleLogin = () => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: GOOGLE_CLIENT_ID,
|
||||||
|
redirect_uri: REDIRECT_URI,
|
||||||
|
response_type: "code",
|
||||||
|
scope: "openid email profile",
|
||||||
|
access_type: "offline",
|
||||||
|
prompt: "consent",
|
||||||
|
});
|
||||||
|
window.location.href = `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -33,8 +40,7 @@ export default function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleGoogleLogin}
|
onClick={handleGoogleLogin}
|
||||||
disabled={isLoading}
|
className="w-full py-3 px-4 bg-white text-gray-800 rounded-lg font-medium hover:bg-gray-100 transition-colors flex items-center justify-center gap-3"
|
||||||
className="w-full py-3 px-4 bg-white text-gray-800 rounded-lg font-medium hover:bg-gray-100 transition-colors disabled:opacity-50 flex items-center justify-center gap-3"
|
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" viewBox="0 0 24 24">
|
<svg className="w-5 h-5" viewBox="0 0 24 24">
|
||||||
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"/>
|
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"/>
|
||||||
@@ -42,7 +48,7 @@ export default function LoginPage() {
|
|||||||
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
||||||
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||||
</svg>
|
</svg>
|
||||||
{isLoading ? "Signing in..." : "Sign in with Google"}
|
Sign in with Google
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
123
sundol-frontend/src/app/settings/page.tsx
Normal file
123
sundol-frontend/src/app/settings/page.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import AuthGuard from "@/components/auth-guard";
|
||||||
|
import NavBar from "@/components/nav-bar";
|
||||||
|
import { useApi } from "@/lib/use-api";
|
||||||
|
|
||||||
|
interface UserProfile {
|
||||||
|
ID: string;
|
||||||
|
EMAIL: string;
|
||||||
|
DISPLAY_NAME: string;
|
||||||
|
AVATAR_URL: string;
|
||||||
|
ENGLISH_LEVEL: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENGLISH_LEVELS = [
|
||||||
|
{ value: "A1", label: "A1 - 입문 (TOEIC 120~225)" },
|
||||||
|
{ value: "A2", label: "A2 - 초급 (TOEIC 225~550, 기초 회화)" },
|
||||||
|
{ value: "B1", label: "B1 - 중급 (TOEIC 550~785, 일상 의사소통)" },
|
||||||
|
{ value: "B2", label: "B2 - 중상급 (TOEIC 785~945, 업무 영어)" },
|
||||||
|
{ value: "C1", label: "C1 - 고급 (TOEIC 945~990, 유창함)" },
|
||||||
|
{ value: "C2", label: "C2 - 원어민 수준" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const { request } = useApi();
|
||||||
|
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [savedMsg, setSavedMsg] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const data = await request<UserProfile>({ method: "GET", url: "/api/users/me" });
|
||||||
|
setProfile(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load profile:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleEnglishLevelChange = async (newLevel: string) => {
|
||||||
|
if (!profile) return;
|
||||||
|
setSaving(true);
|
||||||
|
setSavedMsg("");
|
||||||
|
try {
|
||||||
|
const updated = await request<UserProfile>({
|
||||||
|
method: "PATCH",
|
||||||
|
url: "/api/users/me",
|
||||||
|
data: { englishLevel: newLevel },
|
||||||
|
});
|
||||||
|
setProfile(updated);
|
||||||
|
setSavedMsg("저장되었습니다");
|
||||||
|
setTimeout(() => setSavedMsg(""), 2000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update level:", err);
|
||||||
|
alert("저장에 실패했습니다");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<AuthGuard>
|
||||||
|
<NavBar />
|
||||||
|
<main className="max-w-2xl mx-auto px-4 py-8">
|
||||||
|
<p className="text-[var(--color-text-muted)]">Loading...</p>
|
||||||
|
</main>
|
||||||
|
</AuthGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthGuard>
|
||||||
|
<NavBar />
|
||||||
|
<main className="max-w-2xl mx-auto px-4 py-8">
|
||||||
|
<h1 className="text-2xl font-bold mb-6">설정</h1>
|
||||||
|
|
||||||
|
{/* 프로필 정보 */}
|
||||||
|
<section className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)] mb-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">프로필</h2>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div className="flex">
|
||||||
|
<span className="w-24 text-[var(--color-text-muted)]">이름</span>
|
||||||
|
<span>{profile?.DISPLAY_NAME || "-"}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex">
|
||||||
|
<span className="w-24 text-[var(--color-text-muted)]">이메일</span>
|
||||||
|
<span>{profile?.EMAIL || "-"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 영어 학습 수준 */}
|
||||||
|
<section className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)]">
|
||||||
|
<h2 className="text-lg font-semibold mb-2">영어 학습 수준</h2>
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)] mb-4">
|
||||||
|
영어 컨텐츠를 정리할 때 추출할 학습 표현의 난이도를 결정합니다.
|
||||||
|
</p>
|
||||||
|
<select
|
||||||
|
value={profile?.ENGLISH_LEVEL || "B2"}
|
||||||
|
onChange={(e) => handleEnglishLevelChange(e.target.value)}
|
||||||
|
disabled={saving}
|
||||||
|
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{ENGLISH_LEVELS.map((level) => (
|
||||||
|
<option key={level.value} value={level.value}>
|
||||||
|
{level.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{savedMsg && (
|
||||||
|
<p className="text-sm text-green-400 mt-2">{savedMsg}</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</AuthGuard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,17 +1,177 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import AuthGuard from "@/components/auth-guard";
|
import AuthGuard from "@/components/auth-guard";
|
||||||
import NavBar from "@/components/nav-bar";
|
import NavBar from "@/components/nav-bar";
|
||||||
|
import { useApi } from "@/lib/use-api";
|
||||||
|
|
||||||
|
interface StudyCard {
|
||||||
|
ID: string;
|
||||||
|
KNOWLEDGE_ITEM_ID: string | null;
|
||||||
|
KNOWLEDGE_TITLE: string | null;
|
||||||
|
FRONT: string;
|
||||||
|
BACK: string;
|
||||||
|
EASE_FACTOR: number;
|
||||||
|
INTERVAL_DAYS: number;
|
||||||
|
REPETITIONS: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ratingButtons = [
|
||||||
|
{ value: 0, label: "모름", color: "bg-red-600 hover:bg-red-500" },
|
||||||
|
{ value: 1, label: "거의 모름", color: "bg-red-500 hover:bg-red-400" },
|
||||||
|
{ value: 2, label: "어려움", color: "bg-orange-500 hover:bg-orange-400" },
|
||||||
|
{ value: 3, label: "보통", color: "bg-yellow-500 hover:bg-yellow-400" },
|
||||||
|
{ value: 4, label: "쉬움", color: "bg-green-500 hover:bg-green-400" },
|
||||||
|
{ value: 5, label: "완벽", color: "bg-green-600 hover:bg-green-500" },
|
||||||
|
];
|
||||||
|
|
||||||
export default function StudyPage() {
|
export default function StudyPage() {
|
||||||
|
const { request } = useApi();
|
||||||
|
const [cards, setCards] = useState<StudyCard[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(0);
|
||||||
|
const [showBack, setShowBack] = useState(false);
|
||||||
|
const [reviewing, setReviewing] = useState(false);
|
||||||
|
|
||||||
|
const fetchDueCards = async () => {
|
||||||
|
try {
|
||||||
|
const data = await request<StudyCard[]>({ method: "GET", url: "/api/study-cards/due" });
|
||||||
|
setCards(data);
|
||||||
|
setCurrentIndex(0);
|
||||||
|
setShowBack(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load cards:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchDueCards();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleReview = async (rating: number) => {
|
||||||
|
if (reviewing || !cards[currentIndex]) return;
|
||||||
|
setReviewing(true);
|
||||||
|
try {
|
||||||
|
await request({
|
||||||
|
method: "POST",
|
||||||
|
url: `/api/study-cards/${cards[currentIndex].ID}/review`,
|
||||||
|
data: { rating },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentIndex + 1 < cards.length) {
|
||||||
|
setCurrentIndex(currentIndex + 1);
|
||||||
|
setShowBack(false);
|
||||||
|
} else {
|
||||||
|
// 모든 카드 완료
|
||||||
|
setCards([]);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to review card:", err);
|
||||||
|
} finally {
|
||||||
|
setReviewing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentCard = cards[currentIndex];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthGuard>
|
<AuthGuard>
|
||||||
<NavBar />
|
<NavBar />
|
||||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
<main className="max-w-2xl mx-auto px-4 py-8">
|
||||||
<h1 className="text-2xl font-bold mb-6">Study Cards</h1>
|
<div className="flex justify-between items-center mb-6">
|
||||||
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)] flex items-center justify-center min-h-[40vh]">
|
<h1 className="text-2xl font-bold">Study Cards</h1>
|
||||||
<p className="text-[var(--color-text-muted)]">No cards due for review. Generate cards from your knowledge items.</p>
|
{cards.length > 0 && (
|
||||||
|
<span className="text-sm text-[var(--color-text-muted)]">
|
||||||
|
{currentIndex + 1} / {cards.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-[var(--color-text-muted)]">Loading...</p>
|
||||||
|
) : cards.length === 0 ? (
|
||||||
|
<div className="bg-[var(--color-bg-card)] rounded-xl p-8 border border-[var(--color-border)] text-center">
|
||||||
|
<p className="text-lg mb-2">
|
||||||
|
{currentIndex > 0 ? "복습 완료!" : "복습할 카드가 없습니다."}
|
||||||
|
</p>
|
||||||
|
<p className="text-[var(--color-text-muted)] text-sm">
|
||||||
|
Knowledge 항목에서 카드를 생성해보세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : currentCard ? (
|
||||||
|
<div>
|
||||||
|
{/* Source */}
|
||||||
|
{currentCard.KNOWLEDGE_TITLE && (
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)] mb-2">
|
||||||
|
출처: {currentCard.KNOWLEDGE_TITLE}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Card */}
|
||||||
|
<div
|
||||||
|
className="bg-[var(--color-bg-card)] rounded-xl border border-[var(--color-border)] min-h-[300px] flex flex-col cursor-pointer"
|
||||||
|
onClick={() => !showBack && setShowBack(true)}
|
||||||
|
>
|
||||||
|
{/* Front */}
|
||||||
|
<div className="flex-1 flex items-center justify-center p-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)] mb-3">Question</p>
|
||||||
|
<p className="text-lg whitespace-pre-wrap">{currentCard.FRONT}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Back (revealed) */}
|
||||||
|
{showBack && (
|
||||||
|
<div className="border-t border-[var(--color-border)] flex-1 flex items-center justify-center p-8 bg-[var(--color-bg-hover)]">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)] mb-3">Answer</p>
|
||||||
|
<p className="text-lg whitespace-pre-wrap">{currentCard.BACK}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{!showBack ? (
|
||||||
|
<div className="mt-4 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowBack(true)}
|
||||||
|
className="px-8 py-3 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
정답 보기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-4">
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)] text-center mb-3">
|
||||||
|
얼마나 잘 알고 있나요?
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{ratingButtons.map((btn) => (
|
||||||
|
<button
|
||||||
|
key={btn.value}
|
||||||
|
onClick={() => handleReview(btn.value)}
|
||||||
|
disabled={reviewing}
|
||||||
|
className={`${btn.color} text-white py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-40`}
|
||||||
|
>
|
||||||
|
{btn.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="mt-4 h-1 bg-[var(--color-bg-hover)] rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-[var(--color-primary)] transition-all"
|
||||||
|
style={{ width: `${((currentIndex) / cards.length) * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</main>
|
</main>
|
||||||
</AuthGuard>
|
</AuthGuard>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,22 +1,367 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import AuthGuard from "@/components/auth-guard";
|
import AuthGuard from "@/components/auth-guard";
|
||||||
import NavBar from "@/components/nav-bar";
|
import NavBar from "@/components/nav-bar";
|
||||||
|
import { useApi } from "@/lib/use-api";
|
||||||
|
|
||||||
|
interface Todo {
|
||||||
|
ID: string;
|
||||||
|
PARENT_ID: string | null;
|
||||||
|
TITLE: string;
|
||||||
|
DESCRIPTION: string | null;
|
||||||
|
STATUS: string;
|
||||||
|
PRIORITY: string;
|
||||||
|
DUE_DATE: string | null;
|
||||||
|
SUBTASK_COUNT: number;
|
||||||
|
SUBTASK_DONE_COUNT: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Subtask {
|
||||||
|
ID: string;
|
||||||
|
TITLE: string;
|
||||||
|
STATUS: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const priorityColors: Record<string, string> = {
|
||||||
|
HIGH: "text-red-400",
|
||||||
|
MEDIUM: "text-yellow-400",
|
||||||
|
LOW: "text-green-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
const priorityLabels: Record<string, string> = {
|
||||||
|
HIGH: "높음",
|
||||||
|
MEDIUM: "보통",
|
||||||
|
LOW: "낮음",
|
||||||
|
};
|
||||||
|
|
||||||
export default function TodosPage() {
|
export default function TodosPage() {
|
||||||
|
const { request } = useApi();
|
||||||
|
const [todos, setTodos] = useState<Todo[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [filter, setFilter] = useState<"ALL" | "PENDING" | "DONE">("ALL");
|
||||||
|
|
||||||
|
// Add form
|
||||||
|
const [showAdd, setShowAdd] = useState(false);
|
||||||
|
const [newTitle, setNewTitle] = useState("");
|
||||||
|
const [newPriority, setNewPriority] = useState("MEDIUM");
|
||||||
|
const [newDueDate, setNewDueDate] = useState("");
|
||||||
|
|
||||||
|
// Subtasks
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
const [subtasks, setSubtasks] = useState<Subtask[]>([]);
|
||||||
|
const [newSubtask, setNewSubtask] = useState("");
|
||||||
|
|
||||||
|
const fetchTodos = async () => {
|
||||||
|
try {
|
||||||
|
const status = filter === "ALL" ? undefined : filter;
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (status) params.set("status", status);
|
||||||
|
const data = await request<Todo[]>({ method: "GET", url: `/api/todos?${params}` });
|
||||||
|
setTodos(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load todos:", err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchTodos();
|
||||||
|
}, [filter]);
|
||||||
|
|
||||||
|
const handleAdd = async () => {
|
||||||
|
if (!newTitle.trim()) return;
|
||||||
|
try {
|
||||||
|
await request({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/todos",
|
||||||
|
data: {
|
||||||
|
title: newTitle.trim(),
|
||||||
|
priority: newPriority,
|
||||||
|
dueDate: newDueDate || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setNewTitle("");
|
||||||
|
setNewDueDate("");
|
||||||
|
setShowAdd(false);
|
||||||
|
fetchTodos();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to create todo:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleStatus = async (todo: Todo) => {
|
||||||
|
const newStatus = todo.STATUS === "DONE" ? "PENDING" : "DONE";
|
||||||
|
try {
|
||||||
|
await request({
|
||||||
|
method: "PATCH",
|
||||||
|
url: `/api/todos/${todo.ID}`,
|
||||||
|
data: { status: newStatus },
|
||||||
|
});
|
||||||
|
fetchTodos();
|
||||||
|
if (expandedId === todo.ID) fetchSubtasks(todo.ID);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update todo:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
try {
|
||||||
|
await request({ method: "DELETE", url: `/api/todos/${id}` });
|
||||||
|
if (expandedId === id) setExpandedId(null);
|
||||||
|
fetchTodos();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete todo:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchSubtasks = async (todoId: string) => {
|
||||||
|
try {
|
||||||
|
const data = await request<Subtask[]>({ method: "GET", url: `/api/todos/${todoId}/subtasks` });
|
||||||
|
setSubtasks(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load subtasks:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExpand = (todoId: string) => {
|
||||||
|
if (expandedId === todoId) {
|
||||||
|
setExpandedId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setExpandedId(todoId);
|
||||||
|
fetchSubtasks(todoId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddSubtask = async () => {
|
||||||
|
if (!newSubtask.trim() || !expandedId) return;
|
||||||
|
try {
|
||||||
|
await request({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/todos",
|
||||||
|
data: { title: newSubtask.trim(), priority: "MEDIUM", parentId: expandedId },
|
||||||
|
});
|
||||||
|
setNewSubtask("");
|
||||||
|
fetchSubtasks(expandedId);
|
||||||
|
fetchTodos();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to create subtask:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleSubtask = async (subtask: Subtask) => {
|
||||||
|
const newStatus = subtask.STATUS === "DONE" ? "PENDING" : "DONE";
|
||||||
|
try {
|
||||||
|
await request({
|
||||||
|
method: "PATCH",
|
||||||
|
url: `/api/todos/${subtask.ID}`,
|
||||||
|
data: { status: newStatus },
|
||||||
|
});
|
||||||
|
if (expandedId) fetchSubtasks(expandedId);
|
||||||
|
fetchTodos();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update subtask:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isOverdue = (dueDate: string | null) => {
|
||||||
|
if (!dueDate) return false;
|
||||||
|
return new Date(dueDate) < new Date(new Date().toDateString());
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthGuard>
|
<AuthGuard>
|
||||||
<NavBar />
|
<NavBar />
|
||||||
<main className="max-w-7xl mx-auto px-4 py-8">
|
<main className="max-w-3xl mx-auto px-4 py-8">
|
||||||
<div className="flex justify-between items-center mb-6">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<h1 className="text-2xl font-bold">Todos</h1>
|
<h1 className="text-2xl font-bold">Todos</h1>
|
||||||
<button className="px-4 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors">
|
<button
|
||||||
|
onClick={() => setShowAdd(!showAdd)}
|
||||||
|
className="px-4 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors text-sm"
|
||||||
|
>
|
||||||
+ Add Todo
|
+ Add Todo
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)]">
|
|
||||||
<p className="text-[var(--color-text-muted)]">No todos yet. Create your first task.</p>
|
{/* Add form */}
|
||||||
|
{showAdd && (
|
||||||
|
<div className="bg-[var(--color-bg-card)] rounded-xl p-4 border border-[var(--color-border)] mb-4 space-y-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newTitle}
|
||||||
|
onChange={(e) => setNewTitle(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
||||||
|
placeholder="할 일 입력..."
|
||||||
|
autoFocus
|
||||||
|
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-3 items-center">
|
||||||
|
<select
|
||||||
|
value={newPriority}
|
||||||
|
onChange={(e) => setNewPriority(e.target.value)}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] text-sm focus:outline-none"
|
||||||
|
>
|
||||||
|
<option value="HIGH">높음</option>
|
||||||
|
<option value="MEDIUM">보통</option>
|
||||||
|
<option value="LOW">낮음</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={newDueDate}
|
||||||
|
onChange={(e) => setNewDueDate(e.target.value)}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] text-sm focus:outline-none"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleAdd}
|
||||||
|
disabled={!newTitle.trim()}
|
||||||
|
className="px-4 py-1.5 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] disabled:opacity-40 rounded-lg text-sm transition-colors"
|
||||||
|
>
|
||||||
|
추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex gap-2 mb-4">
|
||||||
|
{(["ALL", "PENDING", "DONE"] as const).map((f) => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
onClick={() => setFilter(f)}
|
||||||
|
className={`px-3 py-1 rounded-lg text-sm transition-colors ${
|
||||||
|
filter === f
|
||||||
|
? "bg-[var(--color-primary)] text-white"
|
||||||
|
: "bg-[var(--color-bg-card)] text-[var(--color-text-muted)] border border-[var(--color-border)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{f === "ALL" ? "전체" : f === "PENDING" ? "진행중" : "완료"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Todo list */}
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-[var(--color-text-muted)]">Loading...</p>
|
||||||
|
) : todos.length === 0 ? (
|
||||||
|
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)]">
|
||||||
|
<p className="text-[var(--color-text-muted)]">
|
||||||
|
{filter === "ALL" ? "아직 할 일이 없습니다." : filter === "PENDING" ? "진행중인 할 일이 없습니다." : "완료된 할 일이 없습니다."}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{todos.map((todo) => (
|
||||||
|
<div key={todo.ID}>
|
||||||
|
<div className="bg-[var(--color-bg-card)] rounded-xl p-4 border border-[var(--color-border)] hover:border-[var(--color-primary)] transition-colors">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Checkbox */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleStatus(todo)}
|
||||||
|
className={`w-5 h-5 rounded border-2 flex-shrink-0 flex items-center justify-center transition-colors ${
|
||||||
|
todo.STATUS === "DONE"
|
||||||
|
? "bg-[var(--color-primary)] border-[var(--color-primary)]"
|
||||||
|
: "border-[var(--color-border)] hover:border-[var(--color-primary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{todo.STATUS === "DONE" && (
|
||||||
|
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`${todo.STATUS === "DONE" ? "line-through text-[var(--color-text-muted)]" : ""}`}>
|
||||||
|
{todo.TITLE}
|
||||||
|
</span>
|
||||||
|
<span className={`text-xs ${priorityColors[todo.PRIORITY]}`}>
|
||||||
|
{priorityLabels[todo.PRIORITY]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 mt-1">
|
||||||
|
{todo.DUE_DATE && (
|
||||||
|
<span className={`text-xs ${isOverdue(todo.DUE_DATE) && todo.STATUS !== "DONE" ? "text-red-400" : "text-[var(--color-text-muted)]"}`}>
|
||||||
|
{todo.DUE_DATE}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{todo.SUBTASK_COUNT > 0 && (
|
||||||
|
<span className="text-xs text-[var(--color-text-muted)]">
|
||||||
|
{todo.SUBTASK_DONE_COUNT}/{todo.SUBTASK_COUNT} subtasks
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleExpand(todo.ID)}
|
||||||
|
className="text-xs text-[var(--color-text-muted)] hover:text-[var(--color-primary)] px-2"
|
||||||
|
>
|
||||||
|
{expandedId === todo.ID ? "▼" : "▶"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(todo.ID)}
|
||||||
|
className="text-xs text-red-400 hover:text-red-300 px-1"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subtasks */}
|
||||||
|
{expandedId === todo.ID && (
|
||||||
|
<div className="ml-8 mt-1 space-y-1">
|
||||||
|
{subtasks.map((st) => (
|
||||||
|
<div
|
||||||
|
key={st.ID}
|
||||||
|
className="flex items-center gap-3 bg-[var(--color-bg-card)] rounded-lg px-3 py-2 border border-[var(--color-border)]"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleSubtask(st)}
|
||||||
|
className={`w-4 h-4 rounded border-2 flex-shrink-0 flex items-center justify-center transition-colors ${
|
||||||
|
st.STATUS === "DONE"
|
||||||
|
? "bg-[var(--color-primary)] border-[var(--color-primary)]"
|
||||||
|
: "border-[var(--color-border)] hover:border-[var(--color-primary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{st.STATUS === "DONE" && (
|
||||||
|
<svg className="w-2.5 h-2.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<span className={`text-sm flex-1 ${st.STATUS === "DONE" ? "line-through text-[var(--color-text-muted)]" : ""}`}>
|
||||||
|
{st.TITLE}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{/* Add subtask */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newSubtask}
|
||||||
|
onChange={(e) => setNewSubtask(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && handleAddSubtask()}
|
||||||
|
placeholder="서브태스크 추가..."
|
||||||
|
className="flex-1 px-3 py-1.5 rounded-lg bg-[var(--color-bg-card)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleAddSubtask}
|
||||||
|
disabled={!newSubtask.trim()}
|
||||||
|
className="px-3 py-1.5 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] disabled:opacity-40 rounded-lg text-xs transition-colors"
|
||||||
|
>
|
||||||
|
추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
</AuthGuard>
|
</AuthGuard>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const navItems = [
|
|||||||
{ href: "/study", label: "Study" },
|
{ href: "/study", label: "Study" },
|
||||||
{ href: "/todos", label: "Todos" },
|
{ href: "/todos", label: "Todos" },
|
||||||
{ href: "/habits", label: "Habits" },
|
{ href: "/habits", label: "Habits" },
|
||||||
|
{ href: "/settings", label: "Settings" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function NavBar() {
|
export default function NavBar() {
|
||||||
|
|||||||
@@ -1,10 +1,110 @@
|
|||||||
import axios from "axios";
|
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
|
||||||
|
|
||||||
export const api = axios.create({
|
export const api = axios.create({
|
||||||
baseURL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080",
|
baseURL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080",
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- 공통 토큰 refresh 로직 (mutex 패턴) ---
|
||||||
|
|
||||||
|
let isRefreshing = false;
|
||||||
|
let pendingQueue: {
|
||||||
|
resolve: (token: string) => void;
|
||||||
|
reject: (error: unknown) => void;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
// auth-context에서 주입하는 콜백
|
||||||
|
let onTokenRefreshed: ((token: string) => void) | null = null;
|
||||||
|
let onRefreshFailed: (() => void) | null = null;
|
||||||
|
|
||||||
|
export function setAuthCallbacks(
|
||||||
|
onRefreshed: (token: string) => void,
|
||||||
|
onFailed: () => void
|
||||||
|
) {
|
||||||
|
onTokenRefreshed = onRefreshed;
|
||||||
|
onRefreshFailed = onFailed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function processQueue(token: string | null, error: unknown) {
|
||||||
|
pendingQueue.forEach(({ resolve, reject }) => {
|
||||||
|
if (token) {
|
||||||
|
resolve(token);
|
||||||
|
} else {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
pendingQueue = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error: AxiosError) => {
|
||||||
|
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
|
||||||
|
|
||||||
|
// 401이 아니거나, refresh 요청 자체가 실패한 경우, 이미 retry한 경우 → 그냥 throw
|
||||||
|
if (
|
||||||
|
error.response?.status !== 401 ||
|
||||||
|
originalRequest.url?.includes("/api/auth/") ||
|
||||||
|
originalRequest._retry
|
||||||
|
) {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 refresh 진행 중이면 큐에 대기
|
||||||
|
if (isRefreshing) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
pendingQueue.push({
|
||||||
|
resolve: (token: string) => {
|
||||||
|
originalRequest._retry = true;
|
||||||
|
originalRequest.headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
resolve(api.request(originalRequest));
|
||||||
|
},
|
||||||
|
reject,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// refresh 시작
|
||||||
|
isRefreshing = true;
|
||||||
|
originalRequest._retry = true;
|
||||||
|
|
||||||
|
const attemptRefresh = async (retryCount: number): Promise<string> => {
|
||||||
|
try {
|
||||||
|
const res = await api.post<LoginResponse>("/api/auth/refresh");
|
||||||
|
return res.data.accessToken;
|
||||||
|
} catch (err) {
|
||||||
|
const isNetworkError = !((err as AxiosError).response);
|
||||||
|
if (isNetworkError && retryCount < 2) {
|
||||||
|
// 네트워크 에러(서버 재시작 등)면 3초 후 재시도
|
||||||
|
await new Promise((r) => setTimeout(r, 3000));
|
||||||
|
return attemptRefresh(retryCount + 1);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newToken = await attemptRefresh(0);
|
||||||
|
|
||||||
|
api.defaults.headers.common["Authorization"] = `Bearer ${newToken}`;
|
||||||
|
onTokenRefreshed?.(newToken);
|
||||||
|
|
||||||
|
// 대기 중인 요청들 처리
|
||||||
|
processQueue(newToken, null);
|
||||||
|
|
||||||
|
// 원래 요청 retry
|
||||||
|
originalRequest.headers["Authorization"] = `Bearer ${newToken}`;
|
||||||
|
return api.request(originalRequest);
|
||||||
|
} catch (refreshError) {
|
||||||
|
processQueue(null, refreshError);
|
||||||
|
onRefreshFailed?.();
|
||||||
|
return Promise.reject(refreshError);
|
||||||
|
} finally {
|
||||||
|
isRefreshing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { createContext, useContext, useState, useCallback, useEffect } from "react";
|
import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from "react";
|
||||||
import { api, LoginResponse } from "./api";
|
import { api, LoginResponse, setAuthCallbacks } from "./api";
|
||||||
|
|
||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
@@ -22,23 +22,53 @@ const AuthContext = createContext<AuthContextType>({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [accessToken, setAccessToken] = useState<string | null>(null);
|
const [accessToken, setAccessTokenState] = useState<string | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const logoutRef = useRef<() => void>(() => {});
|
||||||
|
|
||||||
|
// localStorage와 동기화하는 setter
|
||||||
|
const setAccessToken = useCallback((token: string | null) => {
|
||||||
|
setAccessTokenState(token);
|
||||||
|
if (token) {
|
||||||
|
localStorage.setItem("accessToken", token);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem("accessToken");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// interceptor 콜백 등록
|
||||||
|
useEffect(() => {
|
||||||
|
setAuthCallbacks(
|
||||||
|
(token: string) => setAccessToken(token),
|
||||||
|
() => logoutRef.current()
|
||||||
|
);
|
||||||
|
}, [setAccessToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Try to restore session from refresh token cookie
|
const restoreSession = async () => {
|
||||||
const tryRefresh = async () => {
|
// 1차: localStorage에서 복원
|
||||||
|
const stored = localStorage.getItem("accessToken");
|
||||||
|
if (stored) {
|
||||||
|
setAccessTokenState(stored);
|
||||||
|
api.defaults.headers.common["Authorization"] = `Bearer ${stored}`;
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2차: refresh token cookie로 복원
|
||||||
try {
|
try {
|
||||||
const res = await api.post<LoginResponse>("/api/auth/refresh");
|
const res = await api.post<LoginResponse>("/api/auth/refresh");
|
||||||
setAccessToken(res.data.accessToken);
|
const token = res.data.accessToken;
|
||||||
|
setAccessToken(token);
|
||||||
|
api.defaults.headers.common["Authorization"] = `Bearer ${token}`;
|
||||||
} catch {
|
} catch {
|
||||||
// No valid session
|
// No valid session
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
tryRefresh();
|
restoreSession();
|
||||||
}, []);
|
}, [setAccessToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (accessToken) {
|
if (accessToken) {
|
||||||
@@ -50,7 +80,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
|
|
||||||
const login = useCallback((response: LoginResponse) => {
|
const login = useCallback((response: LoginResponse) => {
|
||||||
setAccessToken(response.accessToken);
|
setAccessToken(response.accessToken);
|
||||||
}, []);
|
}, [setAccessToken]);
|
||||||
|
|
||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -60,7 +90,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
setAccessToken(null);
|
setAccessToken(null);
|
||||||
window.location.href = "/login";
|
window.location.href = "/login";
|
||||||
}, []);
|
}, [setAccessToken]);
|
||||||
|
|
||||||
|
// ref로 최신 logout 유지 (interceptor에서 사용)
|
||||||
|
useEffect(() => {
|
||||||
|
logoutRef.current = logout;
|
||||||
|
}, [logout]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider
|
<AuthContext.Provider
|
||||||
|
|||||||
@@ -1,35 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { api, LoginResponse } from "./api";
|
import { api } from "./api";
|
||||||
import { useAuth } from "./auth-context";
|
|
||||||
import { AxiosRequestConfig } from "axios";
|
import { AxiosRequestConfig } from "axios";
|
||||||
|
|
||||||
export function useApi() {
|
export function useApi() {
|
||||||
const { setAccessToken, logout } = useAuth();
|
|
||||||
|
|
||||||
const request = useCallback(
|
const request = useCallback(
|
||||||
async <T>(config: AxiosRequestConfig): Promise<T> => {
|
async <T>(config: AxiosRequestConfig): Promise<T> => {
|
||||||
try {
|
const response = await api.request<T>(config);
|
||||||
const response = await api.request<T>(config);
|
return response.data;
|
||||||
return response.data;
|
|
||||||
} catch (error: any) {
|
|
||||||
if (error.response?.status === 401) {
|
|
||||||
try {
|
|
||||||
const refreshRes = await api.post<LoginResponse>("/api/auth/refresh");
|
|
||||||
setAccessToken(refreshRes.data.accessToken);
|
|
||||||
api.defaults.headers.common["Authorization"] = `Bearer ${refreshRes.data.accessToken}`;
|
|
||||||
const retryResponse = await api.request<T>(config);
|
|
||||||
return retryResponse.data;
|
|
||||||
} catch {
|
|
||||||
logout();
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[setAccessToken, logout]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
return { request };
|
return { request };
|
||||||
|
|||||||
Reference in New Issue
Block a user