Compare commits

..

14 Commits

Author SHA1 Message Date
6c2129d42e Add category view, pagination, and persist login across deployments
- Add 2-panel category view: sidebar tree + filtered item list
- Category counts use DISTINCT with descendant inclusion
- Hide empty categories, show category badges on item cards
- Add client-side pagination (10 items/page) for both views
- Persist access token in localStorage to survive page refresh
- Fix token refresh retry on backend restart

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 04:11:08 +00:00
f9f710ec90 Add English level settings, improve content structuring and rendering
- Add english_level column to users table (CEFR with TOEIC mapping)
- Add UserController (GET/PATCH /api/users/me) and Settings page
- Enhance structuring prompts: sequential TOC, no summary sections,
  no content overlap, English expression extraction by CEFR level
- Remove sub-TOC analysis (caused content repetition), use simple
  per-section generation with truncation detection and continuation
- Fix CLOB truncation: explicit Clob-to-String conversion in repository
- Replace regex-based markdown rendering with react-markdown
- Add wallet renewal procedure to troubleshooting docs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 23:48:38 +00:00
4cde775809 Switch to user Chrome CDP for YouTube transcript, fix auth and ads
- Replace Playwright standalone browser with CDP connection to user Chrome
  (bypasses YouTube bot detection by using logged-in Chrome session)
- Add video playback, ad detection/skip, and play confirmation before transcript extraction
- Extract transcript JS to separate resource files (fix SyntaxError in evaluate)
- Add ytInitialPlayerResponse-based transcript extraction as primary method
- Fix token refresh: retry on network error during backend restart
- Fix null userId logout, CLOB type hint for structured_content
- Disable XFCE screen lock/screensaver
- Add troubleshooting entries (#10-12) and YouTube transcript guide

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 21:01:49 +00:00
9abb770e37 Add knowledge structuring feature with incremental LLM processing
- Add structured_content column and STRUCTURING pipeline step
- Split LLM structuring into TOC + per-section calls to avoid token limit
- Save intermediate results to DB for real-time frontend polling (3s)
- Add manual "정리하기" button with async processing
- Fix browser login modal by customizing authentication entry point
- Fix standalone build symlinks for server.js and static files
- Add troubleshooting guide

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 19:43:21 +00:00
afc9cdcde6 Refactor Playwright to singleton browser with tab-based crawling
- Add PlaywrightBrowserService: singleton Chromium browser with auto-recovery
- Refactor WebCrawlerService/YouTubeTranscriptService to use shared browser tabs
- Fix YouTube transcript: extract from DOM panel + fmt=json3 fallback
- Keep browser window alive (about:blank instead of page.close)
- Add docs: X Window setup, operation manual, crawling guide

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 19:18:33 +00:00
db4155c36d Add error logging and improve HTTP handling for YouTube transcript fetching
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 18:38:23 +00:00
56d5752095 Add YouTube cookie support to Playwright fallback for bot bypass
Load cookies.txt (Netscape format) into Playwright browser context
before navigating to YouTube, enabling authenticated access to bypass
bot detection that blocks transcript retrieval.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 07:37:48 +00:00
677a79978f Use youtube-transcript-api library with Playwright fallback for YouTube transcripts
Replace Jsoup-based approach with io.github.thoroldvix:youtube-transcript-api
as primary method (supports manual/generated captions, language priority).
Playwright head mode kept as fallback when API fails.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 07:26:52 +00:00
1bfe55d5a8 Switch YouTube transcript fetching from Jsoup to Playwright head mode
Jsoup was blocked by YouTube bot detection. Now uses Playwright with
headed Chromium via Xvfb virtual display to bypass restrictions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 07:11:52 +00:00
9798cda41e Add Axios interceptor for automatic token refresh with mutex pattern
- api.ts: 401 응답 시 자동으로 refresh → retry, 동시 요청은 큐에 대기 (race condition 방지)
- auth-context.tsx: interceptor에 콜백 연결 (토큰 갱신/로그아웃)
- use-api.ts: 401 retry 로직 제거 (interceptor가 처리)
- build.sh: NEXT_PUBLIC 환경변수 검증 단계 추가
- CLAUDE.md: 프론트엔드 빌드 절차 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 04:42:23 +00:00
bb5a601433 Add YouTube transcript auto-fetch button on Knowledge add page
- YouTubeTranscriptService: fetches captions from YouTube page (ko > en > first available)
- GET /api/knowledge/youtube-transcript endpoint
- Frontend: "트랜스크립트 자동 가져오기" button appears when valid YouTube URL entered

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 04:20:13 +00:00
f0f7b62e3d Add Playwright headless browser as 3rd crawling fallback
Crawl chain: Jsoup → Jina Reader → Playwright (headless Chromium).
Error page detection (403, Access Denied, etc.) triggers next fallback.
Switch to exploded classpath for Playwright driver-bundle compatibility.
Fix Next.js standalone static file serving with symlink.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 22:36:24 +00:00
0cc84354f5 Add Jina Reader API fallback for web crawling
Jsoup fails on bot-blocked sites (403). Now tries Jsoup first,
then falls back to Jina Reader (r.jina.ai) for better coverage.
Supports optional API key via JINA_READER_API_KEY env var.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 22:03:09 +00:00
9929322de0 Implement all core features: Knowledge pipeline, RAG chat, Todos, Habits, Study Cards, Tags, Dashboard
- Google OAuth authentication with callback flow
- Knowledge ingest pipeline (TEXT/WEB/YOUTUBE → chunking → categorization → embedding)
- OCI GenAI integration (chat, embeddings) with multi-model support
- Semantic search via Oracle VECTOR_DISTANCE
- RAG-based AI chat with source attribution
- Todos with subtasks, filters, and priority levels
- Habits with daily check-in, streak tracking, and color customization
- Study Cards with SM-2 spaced repetition and LLM auto-generation
- Tags system with knowledge item mapping
- Dashboard with live data from all modules

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 21:43:51 +00:00
62 changed files with 14921 additions and 199 deletions

1
.gitignore vendored
View File

@@ -67,3 +67,4 @@ oracle_data/
# Claude Code
# ========================
.claude/
cookies.txt

View File

@@ -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 compile`
- 프론트엔드 빌드/배포: `cd sundol-frontend && bash build.sh` (환경변수 검증 포함)
- 프론트엔드 빌드 전 `.env``NEXT_PUBLIC_GOOGLE_CLIENT_ID`, `NEXT_PUBLIC_API_URL` 필수. 없으면 Google OAuth 로그인 깨짐.
- 배포 시 반드시 `git push origin main` 포함
# DB 접속 (Oracle Autonomous DB - SQLcl)

223
docs/crawling-guide.md Normal file
View 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
View 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
View 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
View 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회) 대기 후 재시도

View 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 자동 실행 |

View File

@@ -7,6 +7,7 @@ module.exports = {
cwd: "/home/opc/sundol",
env: {
JAVA_HOME: "/usr/lib/jvm/java-21",
PLAYWRIGHT_NODEJS_PATH: "/home/opc/.playwright-driver/driver/linux/node",
},
},
{
@@ -17,6 +18,8 @@ module.exports = {
env: {
PORT: 3000,
HOSTNAME: "0.0.0.0",
NEXT_PUBLIC_API_URL: "https://sundol.cloud-handson.com",
NEXT_PUBLIC_GOOGLE_CLIENT_ID: "906390686133-vpqsisodkg6uqui469hg8dhupbejoa0d.apps.googleusercontent.com",
},
},
],

View File

@@ -6,4 +6,14 @@ set +a
JAVA_HOME=${JAVA_HOME:-/usr/lib/jvm/java-21}
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

View File

@@ -56,10 +56,12 @@
<dependency>
<groupId>com.oracle.database.security</groupId>
<artifactId>osdt_cert</artifactId>
<version>21.18.0.0</version>
</dependency>
<dependency>
<groupId>com.oracle.database.security</groupId>
<artifactId>osdt_core</artifactId>
<version>21.18.0.0</version>
</dependency>
<!-- JWT -->
@@ -81,6 +83,13 @@
<scope>runtime</scope>
</dependency>
<!-- Google Auth Library -->
<dependency>
<groupId>com.google.auth</groupId>
<artifactId>google-auth-library-oauth2-http</artifactId>
<version>1.30.1</version>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
@@ -95,6 +104,25 @@
<version>1.18.3</version>
</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 -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>

View File

@@ -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.ServerHttpSecurity;
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.reactive.CorsConfigurationSource;
import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource;
@@ -41,6 +43,12 @@ public class SecurityConfig {
.addFilterAt(jwtAuthenticationFilter, SecurityWebFiltersOrder.AUTHENTICATION)
.httpBasic(ServerHttpSecurity.HttpBasicSpec::disable)
.formLogin(ServerHttpSecurity.FormLoginSpec::disable)
.exceptionHandling(exceptions -> exceptions
.authenticationEntryPoint((exchange, ex) -> {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
})
)
.build();
}

View File

@@ -1,13 +1,19 @@
package com.sundol.controller;
import com.sundol.dto.LoginRequest;
import com.sundol.dto.LoginResponse;
import com.sundol.dto.RefreshRequest;
import com.sundol.service.AuthService;
import org.springframework.http.HttpCookie;
import org.springframework.http.ResponseCookie;
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 reactor.core.publisher.Mono;
import java.time.Duration;
import java.util.Map;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
@@ -19,19 +25,63 @@ public class AuthController {
}
@PostMapping("/google")
public Mono<ResponseEntity<LoginResponse>> googleLogin(@RequestBody LoginRequest request) {
return authService.googleLogin(request.idToken())
.map(ResponseEntity::ok);
public Mono<ResponseEntity<LoginResponse>> googleLogin(
@RequestBody Map<String, String> body,
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")
public Mono<ResponseEntity<LoginResponse>> refresh(@RequestBody RefreshRequest request) {
return authService.refresh(request.refreshToken())
.map(ResponseEntity::ok);
public Mono<ResponseEntity<LoginResponse>> refresh(ServerHttpRequest request, ServerHttpResponse response) {
HttpCookie cookie = request.getCookies().getFirst("refreshToken");
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")
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)
.then(Mono.just(ResponseEntity.ok().<Void>build()));
}

View File

@@ -1,7 +1,11 @@
package com.sundol.controller;
import com.sundol.dto.IngestRequest;
import com.sundol.repository.CategoryRepository;
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.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
@@ -15,10 +19,18 @@ import java.util.Map;
@RequestMapping("/api/knowledge")
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.youTubeTranscriptService = youTubeTranscriptService;
this.categoryRepository = categoryRepository;
}
@GetMapping
@@ -40,6 +52,19 @@ public class KnowledgeController {
.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}")
public Mono<ResponseEntity<Map<String, Object>>> getById(
@AuthenticationPrincipal String userId,
@@ -65,6 +90,38 @@ public class KnowledgeController {
.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")
public Mono<ResponseEntity<List<Map<String, Object>>>> getChunks(
@AuthenticationPrincipal String userId,

View File

@@ -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()
));
}
}

View File

@@ -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());
}
}

View File

@@ -1,3 +1,3 @@
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) {}

View File

@@ -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
);
}
}

View File

@@ -3,6 +3,9 @@ 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 ChatRepository {
@@ -12,5 +15,75 @@ public class ChatRepository {
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
);
}
}

View File

@@ -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
);
}
}

View File

@@ -3,6 +3,9 @@ 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 HabitRepository {
@@ -12,5 +15,137 @@ public class HabitRepository {
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;
}
}

View File

@@ -3,6 +3,9 @@ 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 KnowledgeChunkRepository {
@@ -12,5 +15,34 @@ public class KnowledgeChunkRepository {
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
);
}
}

View File

@@ -3,6 +3,10 @@ package com.sundol.repository;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.sql.Clob;
import java.util.List;
import java.util.Map;
@Repository
public class KnowledgeRepository {
@@ -12,5 +16,113 @@ public class KnowledgeRepository {
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
);
}
}

View File

@@ -3,6 +3,9 @@ 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 StudyCardRepository {
@@ -12,5 +15,80 @@ public class StudyCardRepository {
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;
}
}

View File

@@ -3,6 +3,9 @@ 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 TagRepository {
@@ -12,5 +15,83 @@ public class TagRepository {
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
);
}
}

View File

@@ -3,6 +3,10 @@ package com.sundol.repository;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@Repository
public class TodoRepository {
@@ -12,5 +16,134 @@ public class TodoRepository {
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;
}
}

View File

@@ -3,6 +3,8 @@ package com.sundol.repository;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.util.Map;
@Repository
public class UserRepository {
@@ -12,5 +14,50 @@ public class UserRepository {
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
);
}
}

View File

@@ -1,34 +1,151 @@
package com.sundol.service;
import com.sundol.dto.LoginResponse;
import com.sundol.exception.AppException;
import com.sundol.repository.UserRepository;
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.web.reactive.function.client.WebClient;
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
public class AuthService {
private static final Logger log = LoggerFactory.getLogger(AuthService.class);
private final UserRepository userRepository;
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) {
this.userRepository = userRepository;
this.jwtProvider = jwtProvider;
this.webClient = WebClient.builder().build();
}
public Mono<LoginResponse> googleLogin(String idToken) {
// TODO: Verify Google ID token, upsert user, issue JWT pair
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
public Mono<LoginResponse> googleLogin(String code) {
// Exchange auth code for tokens via Google token endpoint
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) {
// TODO: Validate refresh token, issue new token pair
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> {
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) {
// TODO: Invalidate refresh token
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromRunnable(() -> {
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);
}
}
}

View File

@@ -1,43 +1,186 @@
package com.sundol.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sundol.exception.AppException;
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 reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Service
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.embeddingRepository = embeddingRepository;
this.genAiService = genAiService;
this.objectMapper = objectMapper;
}
public Mono<List<Map<String, Object>>> listSessions(String userId) {
// TODO: List chat sessions for user
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> chatRepository.listSessions(userId))
.subscribeOn(Schedulers.boundedElastic());
}
public Mono<Map<String, Object>> createSession(String userId) {
// TODO: Create new chat session
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> {
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) {
// TODO: Get messages for chat session
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> {
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) {
// TODO: RAG pipeline - embed query, search, build prompt, call OCI GenAI
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> {
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) {
// TODO: Delete chat session and messages
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromRunnable(() -> chatRepository.deleteSession(userId, sessionId))
.subscribeOn(Schedulers.boundedElastic()).then();
}
}

View File

@@ -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);
}
}

View File

@@ -1,9 +1,12 @@
package com.sundol.service;
import com.sundol.dto.HabitRequest;
import com.sundol.exception.AppException;
import com.sundol.repository.HabitRepository;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.util.List;
import java.util.Map;
@@ -18,32 +21,85 @@ public class HabitService {
}
public Mono<List<Map<String, Object>>> list(String userId) {
// TODO: List habits for user
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> {
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) {
// TODO: Create habit
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> {
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) {
// TODO: Update habit
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> {
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) {
// TODO: Delete habit and logs
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromRunnable(() -> {
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) {
// TODO: Check in for today
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> {
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) {
// TODO: Get habit logs
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> {
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());
}
}

View File

@@ -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);
}
}
}
}

View File

@@ -1,9 +1,15 @@
package com.sundol.service;
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.UserRepository;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.util.List;
import java.util.Map;
@@ -12,38 +18,132 @@ import java.util.Map;
public class KnowledgeService {
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.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) {
// TODO: Query knowledge items with filters
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> knowledgeRepository.list(userId, type, status, search))
.subscribeOn(Schedulers.boundedElastic());
}
public Mono<Map<String, Object>> ingest(String userId, IngestRequest request) {
// TODO: Create knowledge item, trigger async ingest pipeline
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> {
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) {
// TODO: Get knowledge item by ID
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> {
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) {
// TODO: Update knowledge item fields
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> {
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) {
// TODO: Delete knowledge item and all chunks
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromRunnable(() -> knowledgeRepository.delete(userId, id))
.subscribeOn(Schedulers.boundedElastic()).then();
}
public Mono<List<Map<String, Object>>> getChunks(String userId, String id) {
// TODO: List chunks for knowledge item
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> {
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());
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View File

@@ -1,8 +1,11 @@
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 reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.util.List;
import java.util.Map;
@@ -10,14 +13,24 @@ import java.util.Map;
@Service
public class SearchService {
private final KnowledgeChunkRepository chunkRepository;
private static final Logger log = LoggerFactory.getLogger(SearchService.class);
public SearchService(KnowledgeChunkRepository chunkRepository) {
this.chunkRepository = chunkRepository;
private final ChunkEmbeddingRepository embeddingRepository;
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) {
// TODO: Embed query via OCI GenAI, VECTOR_DISTANCE search
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> {
List<float[]> embeddings = genAiService.embedTexts(List.of(query), "SEARCH_QUERY");
float[] queryVector = embeddings.get(0);
return embeddingRepository.searchSimilar(userId, queryVector, topK);
}).subscribeOn(Schedulers.boundedElastic());
}
}

View File

@@ -1,8 +1,17 @@
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 org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.util.List;
import java.util.Map;
@@ -10,29 +19,137 @@ import java.util.Map;
@Service
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.knowledgeRepository = knowledgeRepository;
this.chunkRepository = chunkRepository;
this.genAiService = genAiService;
this.objectMapper = objectMapper;
}
public Mono<List<Map<String, Object>>> getDueCards(String userId) {
// TODO: Get cards due for review today
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> studyCardRepository.getDueCards(userId))
.subscribeOn(Schedulers.boundedElastic());
}
public Mono<List<Map<String, Object>>> getByKnowledgeItem(String userId, String knowledgeItemId) {
// TODO: Get cards for a specific knowledge item
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> studyCardRepository.getByKnowledgeItem(userId, knowledgeItemId))
.subscribeOn(Schedulers.boundedElastic());
}
/**
* Knowledge item의 청크들을 기반으로 LLM이 Q&A 카드를 자동 생성.
*/
public Mono<Map<String, Object>> generate(String userId, String knowledgeItemId) {
// TODO: Trigger AI card generation from knowledge item
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> {
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) {
// TODO: Apply SM-2 algorithm and update card
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> {
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());
}
}

View File

@@ -1,9 +1,12 @@
package com.sundol.service;
import com.sundol.dto.TagRequest;
import com.sundol.exception.AppException;
import com.sundol.repository.TagRepository;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.util.List;
import java.util.Map;
@@ -18,22 +21,41 @@ public class TagService {
}
public Mono<List<Map<String, Object>>> list(String userId) {
// TODO: List tags for user
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> tagRepository.list(userId))
.subscribeOn(Schedulers.boundedElastic());
}
public Mono<Map<String, Object>> create(String userId, TagRequest request) {
// TODO: Create tag
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> {
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) {
// TODO: Update tag
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> {
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) {
// TODO: Delete tag and remove from items
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromRunnable(() -> {
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();
}
}

View File

@@ -1,9 +1,12 @@
package com.sundol.service;
import com.sundol.dto.TodoRequest;
import com.sundol.exception.AppException;
import com.sundol.repository.TodoRepository;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.util.List;
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) {
// TODO: List todos with filters
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> {
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) {
// TODO: Create todo
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> {
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) {
// TODO: Update todo fields
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> {
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) {
// TODO: Delete todo and subtasks
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromRunnable(() -> {
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) {
// TODO: List subtasks for todo
return Mono.error(new UnsupportedOperationException("Not implemented yet"));
return Mono.fromCallable(() -> {
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());
}
}

View File

@@ -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();
}
}
}
}
}
}

View File

@@ -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("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.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;
}
}

View File

@@ -12,12 +12,26 @@ jwt:
access-token-expiry: ${JWT_ACCESS_TOKEN_EXPIRY:900000}
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:
origin: ${CORS_ORIGIN:http://localhost:3000}
oci:
compartment-id: ${OCI_COMPARTMENT_ID:}
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:
level:

View File

@@ -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 '';
}

View File

@@ -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
View 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
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -9,23 +9,23 @@
"lint": "next lint"
},
"dependencies": {
"axios": "^1.7.9",
"lucide-react": "^0.469.0",
"next": "^15.3.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"axios": "^1.7.9",
"zustand": "^5.0.3",
"react-markdown": "^9.0.3",
"lucide-react": "^0.469.0"
"react-markdown": "^9.1.0",
"zustand": "^5.0.3"
},
"devDependencies": {
"typescript": "^5.7.3",
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4.1.0",
"@types/node": "^22.10.0",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@tailwindcss/postcss": "^4.1.0",
"tailwindcss": "^4.1.0",
"eslint": "^9.17.0",
"eslint-config-next": "^15.3.1",
"@eslint/eslintrc": "^3"
"tailwindcss": "^4.1.0",
"typescript": "^5.7.3"
}
}

View File

@@ -1,16 +1,297 @@
"use client";
import { useEffect, useState, useRef } from "react";
import AuthGuard from "@/components/auth-guard";
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() {
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 (
<AuthGuard>
<NavBar />
<main className="max-w-7xl mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-6">AI Chat</h1>
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)] min-h-[60vh] flex items-center justify-center">
<p className="text-[var(--color-text-muted)]">Start a new conversation to ask questions about your knowledge base.</p>
<main className="max-w-7xl mx-auto px-4 py-4 h-[calc(100vh-64px)] flex gap-4">
{/* Sidebar: Sessions */}
<div className="w-64 flex-shrink-0 flex flex-col">
<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>
</main>
</AuthGuard>

View File

@@ -1,33 +1,81 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import AuthGuard from "@/components/auth-guard";
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() {
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 (
<AuthGuard>
<NavBar />
<main className="max-w-7xl mx-auto px-4 py-8">
<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">
<DashCard title="Knowledge Items" value="-" description="Total ingested items" />
<DashCard title="Due Study Cards" value="-" description="Cards due for review" />
<DashCard title="Active Todos" value="-" description="Pending tasks" />
<DashCard title="Habit Streaks" value="-" description="Current active streaks" />
<DashCard title="Chat Sessions" value="-" description="Active conversations" />
<DashCard title="Tags" value="-" description="Knowledge categories" />
</div>
{!data ? (
<p className="text-[var(--color-text-muted)]">Loading...</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{cards.map((card) => (
<Link
key={card.title}
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>
</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>
);
}

View File

@@ -1,22 +1,203 @@
"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 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() {
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 (
<AuthGuard>
<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">
<h1 className="text-2xl font-bold">Habits</h1>
<button className="px-4 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors">
<div>
<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
</button>
</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>
</div>
{/* 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={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>
</AuthGuard>
);

View 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>
);
}

View 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>
);
}

View File

@@ -1,23 +1,475 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import AuthGuard from "@/components/auth-guard";
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() {
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 (
<AuthGuard>
<NavBar />
<main className="max-w-7xl mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Knowledge</h1>
<button className="px-4 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors">
<div className="flex items-center gap-4">
<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
</button>
</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>
</Link>
</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>
</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>
);
}

View 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>
);
}

View File

@@ -1,25 +1,32 @@
"use client";
import { useState } from "react";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
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() {
const router = useRouter();
const { login } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const { isAuthenticated, isLoading } = useAuth();
const handleGoogleLogin = async () => {
setIsLoading(true);
try {
// TODO: Implement Google OAuth flow
// For now, placeholder
alert("Google OAuth not configured yet");
} catch (error) {
console.error("Login failed:", error);
} finally {
setIsLoading(false);
useEffect(() => {
if (!isLoading && isAuthenticated) {
router.replace("/dashboard");
}
}, [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 (
@@ -33,8 +40,7 @@ export default function LoginPage() {
</div>
<button
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 disabled:opacity-50 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 flex items-center justify-center gap-3"
>
<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"/>
@@ -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="#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>
{isLoading ? "Signing in..." : "Sign in with Google"}
Sign in with Google
</button>
</div>
</div>

View 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>
);
}

View File

@@ -1,17 +1,177 @@
"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 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() {
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 (
<AuthGuard>
<NavBar />
<main className="max-w-7xl mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-6">Study Cards</h1>
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)] flex items-center justify-center min-h-[40vh]">
<p className="text-[var(--color-text-muted)]">No cards due for review. Generate cards from your knowledge items.</p>
<main className="max-w-2xl mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Study Cards</h1>
{cards.length > 0 && (
<span className="text-sm text-[var(--color-text-muted)]">
{currentIndex + 1} / {cards.length}
</span>
)}
</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>
</AuthGuard>
);

View File

@@ -1,22 +1,367 @@
"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 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() {
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 (
<AuthGuard>
<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">
<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
</button>
</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>
{/* 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>
</AuthGuard>
);

View File

@@ -11,6 +11,7 @@ const navItems = [
{ href: "/study", label: "Study" },
{ href: "/todos", label: "Todos" },
{ href: "/habits", label: "Habits" },
{ href: "/settings", label: "Settings" },
];
export default function NavBar() {

View File

@@ -1,10 +1,110 @@
import axios from "axios";
import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
export const api = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080",
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
export interface LoginResponse {
accessToken: string;

View File

@@ -1,7 +1,7 @@
"use client";
import React, { createContext, useContext, useState, useCallback, useEffect } from "react";
import { api, LoginResponse } from "./api";
import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from "react";
import { api, LoginResponse, setAuthCallbacks } from "./api";
interface AuthContextType {
isAuthenticated: boolean;
@@ -22,23 +22,53 @@ const AuthContext = createContext<AuthContextType>({
});
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 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(() => {
// Try to restore session from refresh token cookie
const tryRefresh = async () => {
const restoreSession = 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 {
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 {
// No valid session
} finally {
setIsLoading(false);
}
};
tryRefresh();
}, []);
restoreSession();
}, [setAccessToken]);
useEffect(() => {
if (accessToken) {
@@ -50,7 +80,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
const login = useCallback((response: LoginResponse) => {
setAccessToken(response.accessToken);
}, []);
}, [setAccessToken]);
const logout = useCallback(async () => {
try {
@@ -60,7 +90,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
setAccessToken(null);
window.location.href = "/login";
}, []);
}, [setAccessToken]);
// ref로 최신 logout 유지 (interceptor에서 사용)
useEffect(() => {
logoutRef.current = logout;
}, [logout]);
return (
<AuthContext.Provider

View File

@@ -1,35 +1,16 @@
"use client";
import { useCallback } from "react";
import { api, LoginResponse } from "./api";
import { useAuth } from "./auth-context";
import { api } from "./api";
import { AxiosRequestConfig } from "axios";
export function useApi() {
const { setAccessToken, logout } = useAuth();
const request = useCallback(
async <T>(config: AxiosRequestConfig): Promise<T> => {
try {
const response = await api.request<T>(config);
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;
}
const response = await api.request<T>(config);
return response.data;
},
[setAccessToken, logout]
[]
);
return { request };