Compare commits

...

9 Commits

Author SHA1 Message Date
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
49 changed files with 12154 additions and 183 deletions

1
.gitignore vendored
View File

@@ -67,3 +67,4 @@ oracle_data/
# Claude Code # Claude Code
# ======================== # ========================
.claude/ .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 package -q -DskipTests`
- 컴파일만: `cd backend && export JAVA_HOME=/opt/homebrew/Cellar/openjdk/25.0.2/libexec/openjdk.jdk/Contents/Home && set -a && source ../.env && set +a && mvn compile` - 컴파일만: `cd backend && export JAVA_HOME=/opt/homebrew/Cellar/openjdk/25.0.2/libexec/openjdk.jdk/Contents/Home && set -a && source ../.env && set +a && mvn compile`
- 프론트엔드 빌드/배포: `cd sundol-frontend && bash build.sh` (환경변수 검증 포함)
- 프론트엔드 빌드 전 `.env``NEXT_PUBLIC_GOOGLE_CLIENT_ID`, `NEXT_PUBLIC_API_URL` 필수. 없으면 Google OAuth 로그인 깨짐.
- 배포 시 반드시 `git push origin main` 포함 - 배포 시 반드시 `git push origin main` 포함
# DB 접속 (Oracle Autonomous DB - SQLcl) # DB 접속 (Oracle Autonomous DB - SQLcl)

View File

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

View File

@@ -6,4 +6,17 @@ set +a
JAVA_HOME=${JAVA_HOME:-/usr/lib/jvm/java-21} JAVA_HOME=${JAVA_HOME:-/usr/lib/jvm/java-21}
export JAVA_HOME export JAVA_HOME
exec $JAVA_HOME/bin/java -jar /home/opc/sundol/sundol-backend/target/sundol-backend-0.0.1-SNAPSHOT.jar # Playwright: use pre-installed browsers, skip auto-download
export PLAYWRIGHT_BROWSERS_PATH=/home/opc/.cache/ms-playwright
export PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
# Xvfb virtual display for Playwright head mode (YouTube transcript)
export DISPLAY=:99
if ! pgrep -x Xvfb > /dev/null; then
Xvfb :99 -screen 0 1280x720x24 -nolisten tcp &
sleep 1
fi
# 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> <dependency>
<groupId>com.oracle.database.security</groupId> <groupId>com.oracle.database.security</groupId>
<artifactId>osdt_cert</artifactId> <artifactId>osdt_cert</artifactId>
<version>21.18.0.0</version>
</dependency> </dependency>
<dependency> <dependency>
<groupId>com.oracle.database.security</groupId> <groupId>com.oracle.database.security</groupId>
<artifactId>osdt_core</artifactId> <artifactId>osdt_core</artifactId>
<version>21.18.0.0</version>
</dependency> </dependency>
<!-- JWT --> <!-- JWT -->
@@ -81,6 +83,13 @@
<scope>runtime</scope> <scope>runtime</scope>
</dependency> </dependency>
<!-- Google Auth Library -->
<dependency>
<groupId>com.google.auth</groupId>
<artifactId>google-auth-library-oauth2-http</artifactId>
<version>1.30.1</version>
</dependency>
<!-- Lombok --> <!-- Lombok -->
<dependency> <dependency>
<groupId>org.projectlombok</groupId> <groupId>org.projectlombok</groupId>
@@ -95,6 +104,25 @@
<version>1.18.3</version> <version>1.18.3</version>
</dependency> </dependency>
<!-- YouTube Transcript API -->
<dependency>
<groupId>io.github.thoroldvix</groupId>
<artifactId>youtube-transcript-api</artifactId>
<version>0.4.0</version>
</dependency>
<!-- Playwright (headless browser, driver-bundle includes node runtime) -->
<dependency>
<groupId>com.microsoft.playwright</groupId>
<artifactId>playwright</artifactId>
<version>1.51.0</version>
</dependency>
<dependency>
<groupId>com.microsoft.playwright</groupId>
<artifactId>driver-bundle</artifactId>
<version>1.51.0</version>
</dependency>
<!-- Jackson --> <!-- Jackson -->
<dependency> <dependency>
<groupId>com.fasterxml.jackson.core</groupId> <groupId>com.fasterxml.jackson.core</groupId>

View File

@@ -1,13 +1,19 @@
package com.sundol.controller; package com.sundol.controller;
import com.sundol.dto.LoginRequest;
import com.sundol.dto.LoginResponse; import com.sundol.dto.LoginResponse;
import com.sundol.dto.RefreshRequest;
import com.sundol.service.AuthService; import com.sundol.service.AuthService;
import org.springframework.http.HttpCookie;
import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.time.Duration;
import java.util.Map;
@RestController @RestController
@RequestMapping("/api/auth") @RequestMapping("/api/auth")
public class AuthController { public class AuthController {
@@ -19,19 +25,63 @@ public class AuthController {
} }
@PostMapping("/google") @PostMapping("/google")
public Mono<ResponseEntity<LoginResponse>> googleLogin(@RequestBody LoginRequest request) { public Mono<ResponseEntity<LoginResponse>> googleLogin(
return authService.googleLogin(request.idToken()) @RequestBody Map<String, String> body,
.map(ResponseEntity::ok); ServerHttpResponse response) {
String code = body.get("code");
return authService.googleLogin(code)
.map(loginResponse -> {
// Set refresh token as HttpOnly cookie
ResponseCookie cookie = ResponseCookie.from("refreshToken", loginResponse.refreshToken())
.httpOnly(true)
.secure(true)
.path("/api/auth")
.maxAge(Duration.ofDays(30))
.sameSite("Strict")
.build();
response.addCookie(cookie);
return ResponseEntity.ok(loginResponse);
});
} }
@PostMapping("/refresh") @PostMapping("/refresh")
public Mono<ResponseEntity<LoginResponse>> refresh(@RequestBody RefreshRequest request) { public Mono<ResponseEntity<LoginResponse>> refresh(ServerHttpRequest request, ServerHttpResponse response) {
return authService.refresh(request.refreshToken()) HttpCookie cookie = request.getCookies().getFirst("refreshToken");
.map(ResponseEntity::ok); String refreshToken = cookie != null ? cookie.getValue() : null;
if (refreshToken == null) {
return Mono.just(ResponseEntity.status(401).build());
}
return authService.refresh(refreshToken)
.map(loginResponse -> {
// Rotate cookie
ResponseCookie newCookie = ResponseCookie.from("refreshToken", loginResponse.refreshToken())
.httpOnly(true)
.secure(true)
.path("/api/auth")
.maxAge(Duration.ofDays(30))
.sameSite("Strict")
.build();
response.addCookie(newCookie);
return ResponseEntity.ok(loginResponse);
});
} }
@PostMapping("/logout") @PostMapping("/logout")
public Mono<ResponseEntity<Void>> logout(@RequestAttribute("userId") String userId) { public Mono<ResponseEntity<Void>> logout(
@AuthenticationPrincipal String userId,
ServerHttpResponse response) {
// Clear cookie
ResponseCookie cookie = ResponseCookie.from("refreshToken", "")
.httpOnly(true)
.secure(true)
.path("/api/auth")
.maxAge(0)
.sameSite("Strict")
.build();
response.addCookie(cookie);
return authService.logout(userId) return authService.logout(userId)
.then(Mono.just(ResponseEntity.ok().<Void>build())); .then(Mono.just(ResponseEntity.ok().<Void>build()));
} }

View File

@@ -2,6 +2,9 @@ package com.sundol.controller;
import com.sundol.dto.IngestRequest; import com.sundol.dto.IngestRequest;
import com.sundol.service.KnowledgeService; import com.sundol.service.KnowledgeService;
import com.sundol.service.YouTubeTranscriptService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
@@ -15,10 +18,15 @@ import java.util.Map;
@RequestMapping("/api/knowledge") @RequestMapping("/api/knowledge")
public class KnowledgeController { public class KnowledgeController {
private final KnowledgeService knowledgeService; private static final Logger log = LoggerFactory.getLogger(KnowledgeController.class);
public KnowledgeController(KnowledgeService knowledgeService) { private final KnowledgeService knowledgeService;
private final YouTubeTranscriptService youTubeTranscriptService;
public KnowledgeController(KnowledgeService knowledgeService,
YouTubeTranscriptService youTubeTranscriptService) {
this.knowledgeService = knowledgeService; this.knowledgeService = knowledgeService;
this.youTubeTranscriptService = youTubeTranscriptService;
} }
@GetMapping @GetMapping
@@ -40,6 +48,19 @@ public class KnowledgeController {
.map(result -> ResponseEntity.status(HttpStatus.ACCEPTED).body(result)); .map(result -> ResponseEntity.status(HttpStatus.ACCEPTED).body(result));
} }
@GetMapping("/youtube-transcript")
public Mono<ResponseEntity<Map<String, Object>>> fetchYouTubeTranscript(
@AuthenticationPrincipal String userId,
@RequestParam String url) {
return Mono.fromCallable(() -> youTubeTranscriptService.fetchTranscript(url))
.map(transcript -> ResponseEntity.ok(Map.<String, Object>of("transcript", transcript)))
.onErrorResume(e -> {
log.error("YouTube transcript error: {}", e.getMessage(), e);
return Mono.just(ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", e.getMessage() != null ? e.getMessage() : "트랜스크립트를 가져올 수 없습니다")));
});
}
@GetMapping("/{id}") @GetMapping("/{id}")
public Mono<ResponseEntity<Map<String, Object>>> getById( public Mono<ResponseEntity<Map<String, Object>>> getById(
@AuthenticationPrincipal String userId, @AuthenticationPrincipal String userId,

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

@@ -1,3 +1,3 @@
package com.sundol.dto; package com.sundol.dto;
public record IngestRequest(String type, String url, String title, String rawText) {} public record IngestRequest(String type, String url, String title, String rawText, String modelId) {}

View File

@@ -0,0 +1,122 @@
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
);
}
/**
* 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.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Map;
@Repository @Repository
public class ChatRepository { public class ChatRepository {
@@ -12,5 +15,75 @@ public class ChatRepository {
this.jdbcTemplate = jdbcTemplate; this.jdbcTemplate = jdbcTemplate;
} }
// TODO: CRUD for chat_sessions, chat_messages public String createSession(String userId, String title) {
jdbcTemplate.update(
"INSERT INTO chat_sessions (id, user_id, title, created_at, updated_at) " +
"VALUES (SYS_GUID(), HEXTORAW(?), ?, SYSTIMESTAMP, SYSTIMESTAMP)",
userId, title
);
var result = jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id FROM chat_sessions WHERE user_id = HEXTORAW(?) " +
"ORDER BY created_at DESC FETCH FIRST 1 ROW ONLY",
userId
);
return (String) result.get(0).get("ID");
}
public List<Map<String, Object>> listSessions(String userId) {
return jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id, title, created_at, updated_at " +
"FROM chat_sessions WHERE user_id = HEXTORAW(?) ORDER BY updated_at DESC",
userId
);
}
public Map<String, Object> findSession(String userId, String sessionId) {
var results = jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id, title, created_at, updated_at " +
"FROM chat_sessions WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
sessionId, userId
);
return results.isEmpty() ? null : results.get(0);
}
public void updateSessionTitle(String sessionId, String title) {
jdbcTemplate.update(
"UPDATE chat_sessions SET title = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
title, sessionId
);
}
public void touchSession(String sessionId) {
jdbcTemplate.update(
"UPDATE chat_sessions SET updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
sessionId
);
}
public void deleteSession(String userId, String sessionId) {
jdbcTemplate.update(
"DELETE FROM chat_messages WHERE session_id = (SELECT id FROM chat_sessions WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?))",
sessionId, userId
);
jdbcTemplate.update(
"DELETE FROM chat_sessions WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
sessionId, userId
);
}
public void insertMessage(String sessionId, String role, String content, String sourceChunksJson, Integer tokensUsed) {
jdbcTemplate.update(
"INSERT INTO chat_messages (id, session_id, role, content, source_chunks, tokens_used, created_at) " +
"VALUES (SYS_GUID(), HEXTORAW(?), ?, ?, ?, ?, SYSTIMESTAMP)",
sessionId, role, content, sourceChunksJson, tokensUsed
);
}
public List<Map<String, Object>> getMessages(String sessionId) {
return jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id, role, content, source_chunks, tokens_used, created_at " +
"FROM chat_messages WHERE session_id = HEXTORAW(?) ORDER BY created_at ASC",
sessionId
);
}
} }

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.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Map;
@Repository @Repository
public class HabitRepository { public class HabitRepository {
@@ -12,5 +15,137 @@ public class HabitRepository {
this.jdbcTemplate = jdbcTemplate; this.jdbcTemplate = jdbcTemplate;
} }
// TODO: CRUD for habits, habit_logs public String insert(String userId, String name, String description, String habitType, String targetDays, String color) {
jdbcTemplate.update(
"INSERT INTO habits (id, user_id, name, description, habit_type, target_days, color, streak_current, streak_best, is_active, created_at, updated_at) " +
"VALUES (SYS_GUID(), HEXTORAW(?), ?, ?, ?, ?, ?, 0, 0, 1, SYSTIMESTAMP, SYSTIMESTAMP)",
userId, name, description, habitType, targetDays, color
);
var result = jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id FROM habits WHERE user_id = HEXTORAW(?) ORDER BY created_at DESC FETCH FIRST 1 ROW ONLY",
userId
);
return (String) result.get(0).get("ID");
}
public List<Map<String, Object>> list(String userId) {
return jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id, name, description, habit_type, target_days, color, " +
"streak_current, streak_best, is_active, created_at, updated_at " +
"FROM habits WHERE user_id = HEXTORAW(?) AND is_active = 1 ORDER BY created_at ASC",
userId
);
}
public Map<String, Object> findById(String userId, String id) {
var results = jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id, name, description, habit_type, target_days, color, " +
"streak_current, streak_best, is_active, created_at, updated_at " +
"FROM habits WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
id, userId
);
return results.isEmpty() ? null : results.get(0);
}
public void updateName(String id, String name) {
jdbcTemplate.update(
"UPDATE habits SET name = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
name, id
);
}
public void updateStreak(String id, int current, int best) {
jdbcTemplate.update(
"UPDATE habits SET streak_current = ?, streak_best = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
current, best, id
);
}
public void deactivate(String id) {
jdbcTemplate.update(
"UPDATE habits SET is_active = 0, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
id
);
}
public void delete(String userId, String id) {
jdbcTemplate.update(
"DELETE FROM habit_logs WHERE habit_id = (SELECT id FROM habits WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?))",
id, userId
);
jdbcTemplate.update(
"DELETE FROM habits WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
id, userId
);
}
// --- Habit Logs ---
public void insertLog(String habitId, String note) {
jdbcTemplate.update(
"INSERT INTO habit_logs (id, habit_id, log_date, checked_in, note, created_at) " +
"VALUES (SYS_GUID(), HEXTORAW(?), TRUNC(SYSDATE), 1, ?, SYSTIMESTAMP)",
habitId, note
);
}
public boolean hasCheckedInToday(String habitId) {
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM habit_logs WHERE habit_id = HEXTORAW(?) AND log_date = TRUNC(SYSDATE) AND checked_in = 1",
Integer.class, habitId
);
return count != null && count > 0;
}
public List<Map<String, Object>> getLogs(String habitId, String from, String to) {
StringBuilder sql = new StringBuilder(
"SELECT RAWTOHEX(id) AS id, TO_CHAR(log_date, 'YYYY-MM-DD') AS log_date, checked_in, note, created_at " +
"FROM habit_logs WHERE habit_id = HEXTORAW(?)"
);
java.util.ArrayList<Object> params = new java.util.ArrayList<>();
params.add(habitId);
if (from != null && !from.isEmpty()) {
sql.append(" AND log_date >= TO_DATE(?, 'YYYY-MM-DD')");
params.add(from);
}
if (to != null && !to.isEmpty()) {
sql.append(" AND log_date <= TO_DATE(?, 'YYYY-MM-DD')");
params.add(to);
}
sql.append(" ORDER BY log_date DESC");
return jdbcTemplate.queryForList(sql.toString(), params.toArray());
}
/**
* 연속 체크인 일수 계산 (오늘부터 역순)
*/
public int calculateCurrentStreak(String habitId) {
List<Map<String, Object>> logs = jdbcTemplate.queryForList(
"SELECT TO_CHAR(log_date, 'YYYY-MM-DD') AS log_date FROM habit_logs " +
"WHERE habit_id = HEXTORAW(?) AND checked_in = 1 ORDER BY log_date DESC",
habitId
);
if (logs.isEmpty()) return 0;
int streak = 0;
java.time.LocalDate expected = java.time.LocalDate.now();
for (Map<String, Object> log : logs) {
java.time.LocalDate logDate = java.time.LocalDate.parse((String) log.get("LOG_DATE"));
if (logDate.equals(expected)) {
streak++;
expected = expected.minusDays(1);
} else if (logDate.equals(expected.minusDays(1)) && streak == 0) {
// 어제까지 연속이면 (오늘 아직 안 한 경우)
expected = logDate;
streak++;
expected = expected.minusDays(1);
} else {
break;
}
}
return streak;
}
} }

View File

@@ -3,6 +3,9 @@ package com.sundol.repository;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Map;
@Repository @Repository
public class KnowledgeChunkRepository { public class KnowledgeChunkRepository {
@@ -12,5 +15,34 @@ public class KnowledgeChunkRepository {
this.jdbcTemplate = jdbcTemplate; this.jdbcTemplate = jdbcTemplate;
} }
// TODO: CRUD for knowledge_chunks, VECTOR_DISTANCE search public void insertChunk(String knowledgeItemId, int chunkIndex, String content, int tokenCount) {
jdbcTemplate.update(
"INSERT INTO knowledge_chunks (id, knowledge_item_id, chunk_index, content, token_count, created_at) " +
"VALUES (SYS_GUID(), HEXTORAW(?), ?, ?, ?, SYSTIMESTAMP)",
knowledgeItemId, chunkIndex, content, tokenCount
);
}
public List<Map<String, Object>> findByKnowledgeItemId(String knowledgeItemId) {
return jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id, chunk_index, content, token_count, created_at " +
"FROM knowledge_chunks WHERE knowledge_item_id = HEXTORAW(?) ORDER BY chunk_index",
knowledgeItemId
);
}
public int countByKnowledgeItemId(String knowledgeItemId) {
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM knowledge_chunks WHERE knowledge_item_id = HEXTORAW(?)",
Integer.class, knowledgeItemId
);
return count != null ? count : 0;
}
public void deleteByKnowledgeItemId(String knowledgeItemId) {
jdbcTemplate.update(
"DELETE FROM knowledge_chunks WHERE knowledge_item_id = HEXTORAW(?)",
knowledgeItemId
);
}
} }

View File

@@ -3,6 +3,9 @@ package com.sundol.repository;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Map;
@Repository @Repository
public class KnowledgeRepository { public class KnowledgeRepository {
@@ -12,5 +15,80 @@ public class KnowledgeRepository {
this.jdbcTemplate = jdbcTemplate; this.jdbcTemplate = jdbcTemplate;
} }
// TODO: CRUD for knowledge_items table public String insert(String userId, String type, String title, String sourceUrl, String rawText) {
jdbcTemplate.update(
"INSERT INTO knowledge_items (id, user_id, type, title, source_url, raw_text, status, created_at, updated_at) " +
"VALUES (SYS_GUID(), HEXTORAW(?), ?, ?, ?, ?, 'PENDING', SYSTIMESTAMP, SYSTIMESTAMP)",
userId, type, title, sourceUrl, rawText
);
// Get the ID of the just-inserted row
var result = jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id FROM knowledge_items WHERE user_id = HEXTORAW(?) ORDER BY created_at DESC FETCH FIRST 1 ROW ONLY",
userId
);
return (String) result.get(0).get("ID");
}
public List<Map<String, Object>> list(String userId, String type, String status, String search) {
StringBuilder sql = new StringBuilder(
"SELECT RAWTOHEX(id) AS id, type, title, source_url, status, created_at, updated_at FROM knowledge_items WHERE user_id = HEXTORAW(?)"
);
java.util.List<Object> params = new java.util.ArrayList<>();
params.add(userId);
if (type != null && !type.isEmpty()) {
sql.append(" AND type = ?");
params.add(type);
}
if (status != null && !status.isEmpty()) {
sql.append(" AND status = ?");
params.add(status);
}
if (search != null && !search.isEmpty()) {
sql.append(" AND UPPER(title) LIKE UPPER(?)");
params.add("%" + search + "%");
}
sql.append(" ORDER BY 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, status, created_at, updated_at " +
"FROM knowledge_items WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
id, userId
);
return results.isEmpty() ? null : 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, status, created_at, updated_at " +
"FROM knowledge_items WHERE RAWTOHEX(id) = ?",
id
);
return results.isEmpty() ? null : results.get(0);
}
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 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.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Map;
@Repository @Repository
public class StudyCardRepository { public class StudyCardRepository {
@@ -12,5 +15,80 @@ public class StudyCardRepository {
this.jdbcTemplate = jdbcTemplate; this.jdbcTemplate = jdbcTemplate;
} }
// TODO: CRUD for study_cards, SM-2 queries public String insert(String userId, String knowledgeItemId, String front, String back) {
if (knowledgeItemId != null) {
jdbcTemplate.update(
"INSERT INTO study_cards (id, user_id, knowledge_item_id, front, back, ease_factor, interval_days, repetitions, next_review_at, created_at, updated_at) " +
"VALUES (SYS_GUID(), HEXTORAW(?), HEXTORAW(?), ?, ?, 2.50, 0, 0, SYSTIMESTAMP, SYSTIMESTAMP, SYSTIMESTAMP)",
userId, knowledgeItemId, front, back
);
} else {
jdbcTemplate.update(
"INSERT INTO study_cards (id, user_id, knowledge_item_id, front, back, ease_factor, interval_days, repetitions, next_review_at, created_at, updated_at) " +
"VALUES (SYS_GUID(), HEXTORAW(?), NULL, ?, ?, 2.50, 0, 0, SYSTIMESTAMP, SYSTIMESTAMP, SYSTIMESTAMP)",
userId, front, back
);
}
var result = jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id FROM study_cards WHERE user_id = HEXTORAW(?) ORDER BY created_at DESC FETCH FIRST 1 ROW ONLY",
userId
);
return (String) result.get(0).get("ID");
}
public List<Map<String, Object>> getDueCards(String userId) {
return jdbcTemplate.queryForList(
"SELECT RAWTOHEX(sc.id) AS id, RAWTOHEX(sc.knowledge_item_id) AS knowledge_item_id, " +
"sc.front, sc.back, sc.ease_factor, sc.interval_days, sc.repetitions, sc.next_review_at, " +
"ki.title AS knowledge_title " +
"FROM study_cards sc " +
"LEFT JOIN knowledge_items ki ON ki.id = sc.knowledge_item_id " +
"WHERE sc.user_id = HEXTORAW(?) AND sc.next_review_at <= SYSTIMESTAMP " +
"ORDER BY sc.next_review_at ASC",
userId
);
}
public List<Map<String, Object>> getByKnowledgeItem(String userId, String knowledgeItemId) {
return jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id, front, back, ease_factor, interval_days, repetitions, next_review_at, created_at " +
"FROM study_cards WHERE user_id = HEXTORAW(?) AND knowledge_item_id = HEXTORAW(?) ORDER BY created_at ASC",
userId, knowledgeItemId
);
}
public Map<String, Object> findById(String userId, String id) {
var results = jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id, RAWTOHEX(knowledge_item_id) AS knowledge_item_id, " +
"front, back, ease_factor, interval_days, repetitions, next_review_at, created_at, updated_at " +
"FROM study_cards WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
id, userId
);
return results.isEmpty() ? null : results.get(0);
}
public void updateSm2(String id, double easeFactor, int intervalDays, int repetitions, int nextReviewDaysFromNow) {
jdbcTemplate.update(
"UPDATE study_cards SET ease_factor = ?, interval_days = ?, repetitions = ?, " +
"next_review_at = SYSTIMESTAMP + INTERVAL '1' DAY * ?, updated_at = SYSTIMESTAMP " +
"WHERE RAWTOHEX(id) = ?",
easeFactor, intervalDays, repetitions, nextReviewDaysFromNow, id
);
}
public int countDue(String userId) {
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM study_cards WHERE user_id = HEXTORAW(?) AND next_review_at <= SYSTIMESTAMP",
Integer.class, userId
);
return count != null ? count : 0;
}
public int countByKnowledgeItem(String knowledgeItemId) {
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM study_cards WHERE knowledge_item_id = HEXTORAW(?)",
Integer.class, knowledgeItemId
);
return count != null ? count : 0;
}
} }

View File

@@ -3,6 +3,9 @@ package com.sundol.repository;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Map;
@Repository @Repository
public class TagRepository { public class TagRepository {
@@ -12,5 +15,83 @@ public class TagRepository {
this.jdbcTemplate = jdbcTemplate; this.jdbcTemplate = jdbcTemplate;
} }
// TODO: CRUD for tags, knowledge_item_tags public String insert(String userId, String name, String color) {
jdbcTemplate.update(
"INSERT INTO tags (id, user_id, name, color, created_at) " +
"VALUES (SYS_GUID(), HEXTORAW(?), ?, ?, SYSTIMESTAMP)",
userId, name, color
);
var result = jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id FROM tags WHERE user_id = HEXTORAW(?) ORDER BY created_at DESC FETCH FIRST 1 ROW ONLY",
userId
);
return (String) result.get(0).get("ID");
}
public List<Map<String, Object>> list(String userId) {
return jdbcTemplate.queryForList(
"SELECT RAWTOHEX(t.id) AS id, t.name, t.color, t.created_at, " +
"(SELECT COUNT(*) FROM knowledge_item_tags kit WHERE kit.tag_id = t.id) AS item_count " +
"FROM tags t WHERE t.user_id = HEXTORAW(?) ORDER BY t.name",
userId
);
}
public Map<String, Object> findById(String userId, String id) {
var results = jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id, name, color, created_at " +
"FROM tags WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
id, userId
);
return results.isEmpty() ? null : results.get(0);
}
public void updateName(String id, String name) {
jdbcTemplate.update("UPDATE tags SET name = ? WHERE RAWTOHEX(id) = ?", name, id);
}
public void updateColor(String id, String color) {
jdbcTemplate.update("UPDATE tags SET color = ? WHERE RAWTOHEX(id) = ?", color, id);
}
public void delete(String userId, String id) {
jdbcTemplate.update(
"DELETE FROM knowledge_item_tags WHERE tag_id = (SELECT id FROM tags WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?))",
id, userId
);
jdbcTemplate.update(
"DELETE FROM tags WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
id, userId
);
}
// --- Knowledge Item ↔ Tag 매핑 ---
public void tagItem(String knowledgeItemId, String tagId) {
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM knowledge_item_tags WHERE knowledge_item_id = HEXTORAW(?) AND tag_id = HEXTORAW(?)",
Integer.class, knowledgeItemId, tagId
);
if (count != null && count > 0) return;
jdbcTemplate.update(
"INSERT INTO knowledge_item_tags (knowledge_item_id, tag_id) VALUES (HEXTORAW(?), HEXTORAW(?))",
knowledgeItemId, tagId
);
}
public void untagItem(String knowledgeItemId, String tagId) {
jdbcTemplate.update(
"DELETE FROM knowledge_item_tags WHERE knowledge_item_id = HEXTORAW(?) AND tag_id = HEXTORAW(?)",
knowledgeItemId, tagId
);
}
public List<Map<String, Object>> getTagsForItem(String knowledgeItemId) {
return jdbcTemplate.queryForList(
"SELECT RAWTOHEX(t.id) AS id, t.name, t.color " +
"FROM tags t JOIN knowledge_item_tags kit ON kit.tag_id = t.id " +
"WHERE kit.knowledge_item_id = HEXTORAW(?) ORDER BY t.name",
knowledgeItemId
);
}
} }

View File

@@ -3,6 +3,10 @@ package com.sundol.repository;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@Repository @Repository
public class TodoRepository { public class TodoRepository {
@@ -12,5 +16,134 @@ public class TodoRepository {
this.jdbcTemplate = jdbcTemplate; this.jdbcTemplate = jdbcTemplate;
} }
// TODO: CRUD for todos table public String insert(String userId, String title, String description, String priority, String dueDate, String parentId) {
if (parentId != null) {
jdbcTemplate.update(
"INSERT INTO todos (id, user_id, parent_id, title, description, status, priority, due_date, created_at, updated_at) " +
"VALUES (SYS_GUID(), HEXTORAW(?), HEXTORAW(?), ?, ?, 'PENDING', ?, " +
"CASE WHEN ? IS NOT NULL THEN TO_DATE(?, 'YYYY-MM-DD') ELSE NULL END, SYSTIMESTAMP, SYSTIMESTAMP)",
userId, parentId, title, description, priority, dueDate, dueDate
);
} else {
jdbcTemplate.update(
"INSERT INTO todos (id, user_id, parent_id, title, description, status, priority, due_date, created_at, updated_at) " +
"VALUES (SYS_GUID(), HEXTORAW(?), NULL, ?, ?, 'PENDING', ?, " +
"CASE WHEN ? IS NOT NULL THEN TO_DATE(?, 'YYYY-MM-DD') ELSE NULL END, SYSTIMESTAMP, SYSTIMESTAMP)",
userId, title, description, priority, dueDate, dueDate
);
}
var result = jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id FROM todos WHERE user_id = HEXTORAW(?) ORDER BY created_at DESC FETCH FIRST 1 ROW ONLY",
userId
);
return (String) result.get(0).get("ID");
}
public List<Map<String, Object>> list(String userId, String status, String priority, String dueDate) {
StringBuilder sql = new StringBuilder(
"SELECT RAWTOHEX(id) AS id, RAWTOHEX(parent_id) AS parent_id, title, description, status, priority, " +
"TO_CHAR(due_date, 'YYYY-MM-DD') AS due_date, created_at, updated_at " +
"FROM todos WHERE user_id = HEXTORAW(?) AND parent_id IS NULL"
);
List<Object> params = new ArrayList<>();
params.add(userId);
if (status != null && !status.isEmpty()) {
sql.append(" AND status = ?");
params.add(status);
}
if (priority != null && !priority.isEmpty()) {
sql.append(" AND priority = ?");
params.add(priority);
}
if (dueDate != null && !dueDate.isEmpty()) {
sql.append(" AND due_date <= TO_DATE(?, 'YYYY-MM-DD')");
params.add(dueDate);
}
sql.append(" ORDER BY CASE priority WHEN 'HIGH' THEN 1 WHEN 'MEDIUM' THEN 2 WHEN 'LOW' THEN 3 END, created_at DESC");
return jdbcTemplate.queryForList(sql.toString(), params.toArray());
}
public Map<String, Object> findById(String userId, String id) {
var results = jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id, RAWTOHEX(parent_id) AS parent_id, title, description, status, priority, " +
"TO_CHAR(due_date, 'YYYY-MM-DD') AS due_date, created_at, updated_at " +
"FROM todos WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
id, userId
);
return results.isEmpty() ? null : results.get(0);
}
public List<Map<String, Object>> findSubtasks(String userId, String parentId) {
return jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id, RAWTOHEX(parent_id) AS parent_id, title, description, status, priority, " +
"TO_CHAR(due_date, 'YYYY-MM-DD') AS due_date, created_at, updated_at " +
"FROM todos WHERE parent_id = HEXTORAW(?) AND user_id = HEXTORAW(?) ORDER BY created_at ASC",
parentId, userId
);
}
public void updateStatus(String id, String status) {
jdbcTemplate.update(
"UPDATE todos SET status = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
status, id
);
}
public void updateTitle(String id, String title) {
jdbcTemplate.update(
"UPDATE todos SET title = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
title, id
);
}
public void updatePriority(String id, String priority) {
jdbcTemplate.update(
"UPDATE todos SET priority = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
priority, id
);
}
public void updateDueDate(String id, String dueDate) {
if (dueDate != null && !dueDate.isEmpty()) {
jdbcTemplate.update(
"UPDATE todos SET due_date = TO_DATE(?, 'YYYY-MM-DD'), updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
dueDate, id
);
} else {
jdbcTemplate.update(
"UPDATE todos SET due_date = NULL, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
id
);
}
}
public void delete(String userId, String id) {
// 서브태스크 먼저 삭제
jdbcTemplate.update(
"DELETE FROM todos WHERE parent_id = HEXTORAW(?) AND user_id = HEXTORAW(?)",
id, userId
);
jdbcTemplate.update(
"DELETE FROM todos WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
id, userId
);
}
public int countByUser(String userId, String status) {
if (status != null) {
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM todos WHERE user_id = HEXTORAW(?) AND status = ?",
Integer.class, userId, status
);
return count != null ? count : 0;
}
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM todos WHERE user_id = HEXTORAW(?)",
Integer.class, userId
);
return count != null ? count : 0;
}
} }

View File

@@ -3,6 +3,8 @@ package com.sundol.repository;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.Map;
@Repository @Repository
public class UserRepository { public class UserRepository {
@@ -12,5 +14,43 @@ public class UserRepository {
this.jdbcTemplate = jdbcTemplate; this.jdbcTemplate = jdbcTemplate;
} }
// TODO: findByGoogleSub, upsert, updateRefreshToken public Map<String, Object> findByGoogleSub(String googleSub) {
var results = jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id, email, display_name, avatar_url, google_sub 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 FROM users WHERE RAWTOHEX(id) = ?",
userId
);
return results.isEmpty() ? null : results.get(0);
}
} }

View File

@@ -1,34 +1,147 @@
package com.sundol.service; package com.sundol.service;
import com.sundol.dto.LoginResponse; import com.sundol.dto.LoginResponse;
import com.sundol.exception.AppException;
import com.sundol.repository.UserRepository; import com.sundol.repository.UserRepository;
import com.sundol.security.JwtProvider; import com.sundol.security.JwtProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.Map;
@Service @Service
public class AuthService { public class AuthService {
private static final Logger log = LoggerFactory.getLogger(AuthService.class);
private final UserRepository userRepository; private final UserRepository userRepository;
private final JwtProvider jwtProvider; private final JwtProvider jwtProvider;
private final WebClient webClient;
@Value("${google.client-id}")
private String googleClientId;
@Value("${google.client-secret}")
private String googleClientSecret;
@Value("${google.redirect-uri}")
private String googleRedirectUri;
public AuthService(UserRepository userRepository, JwtProvider jwtProvider) { public AuthService(UserRepository userRepository, JwtProvider jwtProvider) {
this.userRepository = userRepository; this.userRepository = userRepository;
this.jwtProvider = jwtProvider; this.jwtProvider = jwtProvider;
this.webClient = WebClient.builder().build();
} }
public Mono<LoginResponse> googleLogin(String idToken) { public Mono<LoginResponse> googleLogin(String code) {
// TODO: Verify Google ID token, upsert user, issue JWT pair // Exchange auth code for tokens via Google token endpoint
return Mono.error(new UnsupportedOperationException("Not implemented yet")); return webClient.post()
.uri("https://oauth2.googleapis.com/token")
.header("Content-Type", "application/x-www-form-urlencoded")
.bodyValue("code=" + code
+ "&client_id=" + googleClientId
+ "&client_secret=" + googleClientSecret
+ "&redirect_uri=" + googleRedirectUri
+ "&grant_type=authorization_code")
.retrieve()
.bodyToMono(Map.class)
.flatMap(tokenResponse -> {
String accessToken = (String) tokenResponse.get("access_token");
if (accessToken == null) {
return Mono.error(new AppException(HttpStatus.UNAUTHORIZED, "Failed to exchange auth code"));
}
// Get user info from Google
return webClient.get()
.uri("https://www.googleapis.com/oauth2/v2/userinfo")
.header("Authorization", "Bearer " + accessToken)
.retrieve()
.bodyToMono(Map.class);
})
.flatMap(userInfo -> Mono.fromCallable(() -> {
String googleSub = (String) userInfo.get("id");
String email = (String) userInfo.get("email");
String name = (String) userInfo.get("name");
String picture = (String) userInfo.get("picture");
if (googleSub == null || email == null) {
throw new AppException(HttpStatus.UNAUTHORIZED, "Invalid Google user info");
}
// Upsert user
Map<String, Object> user = userRepository.upsert(email, name, picture, googleSub);
String userId = (String) user.get("ID");
// Create JWT pair
String jwt = jwtProvider.createAccessToken(userId, email);
String refreshToken = jwtProvider.createRefreshToken(userId, email);
// Store hashed refresh token
userRepository.updateRefreshToken(userId, sha256(refreshToken));
log.info("User logged in: {} ({})", email, userId);
return new LoginResponse(jwt, refreshToken);
}).subscribeOn(Schedulers.boundedElastic()));
} }
public Mono<LoginResponse> refresh(String refreshToken) { public Mono<LoginResponse> refresh(String refreshToken) {
// TODO: Validate refresh token, issue new token pair return Mono.fromCallable(() -> {
return Mono.error(new UnsupportedOperationException("Not implemented yet")); if (!jwtProvider.validateToken(refreshToken)) {
throw new AppException(HttpStatus.UNAUTHORIZED, "Invalid refresh token");
}
var claims = jwtProvider.parseToken(refreshToken);
String type = claims.get("type", String.class);
if (!"REFRESH".equals(type)) {
throw new AppException(HttpStatus.UNAUTHORIZED, "Not a refresh token");
}
String userId = claims.getSubject();
Map<String, Object> user = userRepository.findById(userId);
if (user == null) {
throw new AppException(HttpStatus.UNAUTHORIZED, "User not found");
}
// Verify stored hash matches
String storedHash = (String) user.get("REFRESH_TOKEN");
if (storedHash == null || !storedHash.equals(sha256(refreshToken))) {
throw new AppException(HttpStatus.UNAUTHORIZED, "Refresh token revoked");
}
String email = (String) user.get("EMAIL");
// Issue new pair (rotation)
String newAccessToken = jwtProvider.createAccessToken(userId, email);
String newRefreshToken = jwtProvider.createRefreshToken(userId, email);
userRepository.updateRefreshToken(userId, sha256(newRefreshToken));
return new LoginResponse(newAccessToken, newRefreshToken);
}).subscribeOn(Schedulers.boundedElastic());
} }
public Mono<Void> logout(String userId) { public Mono<Void> logout(String userId) {
// TODO: Invalidate refresh token return Mono.fromRunnable(() -> {
return Mono.error(new UnsupportedOperationException("Not implemented yet")); 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; package com.sundol.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sundol.exception.AppException;
import com.sundol.repository.ChatRepository; import com.sundol.repository.ChatRepository;
import com.sundol.repository.ChunkEmbeddingRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors;
@Service @Service
public class ChatService { public class ChatService {
private final ChatRepository chatRepository; private static final Logger log = LoggerFactory.getLogger(ChatService.class);
private static final int RAG_TOP_K = 5;
public ChatService(ChatRepository chatRepository) { private final ChatRepository chatRepository;
private final ChunkEmbeddingRepository embeddingRepository;
private final OciGenAiService genAiService;
private final ObjectMapper objectMapper;
public ChatService(
ChatRepository chatRepository,
ChunkEmbeddingRepository embeddingRepository,
OciGenAiService genAiService,
ObjectMapper objectMapper) {
this.chatRepository = chatRepository; this.chatRepository = chatRepository;
this.embeddingRepository = embeddingRepository;
this.genAiService = genAiService;
this.objectMapper = objectMapper;
} }
public Mono<List<Map<String, Object>>> listSessions(String userId) { public Mono<List<Map<String, Object>>> listSessions(String userId) {
// TODO: List chat sessions for user return Mono.fromCallable(() -> chatRepository.listSessions(userId))
return Mono.error(new UnsupportedOperationException("Not implemented yet")); .subscribeOn(Schedulers.boundedElastic());
} }
public Mono<Map<String, Object>> createSession(String userId) { public Mono<Map<String, Object>> createSession(String userId) {
// TODO: Create new chat session return Mono.fromCallable(() -> {
return Mono.error(new UnsupportedOperationException("Not implemented yet")); String id = chatRepository.createSession(userId, "New Chat");
return chatRepository.findSession(userId, id);
}).subscribeOn(Schedulers.boundedElastic());
} }
public Mono<List<Map<String, Object>>> getMessages(String userId, String sessionId) { public Mono<List<Map<String, Object>>> getMessages(String userId, String sessionId) {
// TODO: Get messages for chat session return Mono.fromCallable(() -> {
return Mono.error(new UnsupportedOperationException("Not implemented yet")); Map<String, Object> session = chatRepository.findSession(userId, sessionId);
if (session == null) {
throw new AppException(HttpStatus.NOT_FOUND, "Chat session not found");
}
return chatRepository.getMessages(sessionId);
}).subscribeOn(Schedulers.boundedElastic());
} }
public Mono<Map<String, Object>> sendMessage(String userId, String sessionId, String content) { public Mono<Map<String, Object>> sendMessage(String userId, String sessionId, String content) {
// TODO: RAG pipeline - embed query, search, build prompt, call OCI GenAI return Mono.fromCallable(() -> {
return Mono.error(new UnsupportedOperationException("Not implemented yet")); Map<String, Object> session = chatRepository.findSession(userId, sessionId);
if (session == null) {
throw new AppException(HttpStatus.NOT_FOUND, "Chat session not found");
}
// 1. 사용자 메시지 저장
chatRepository.insertMessage(sessionId, "user", content, null, null);
// 2. RAG: 쿼리 임베딩 → 유사 청크 검색
List<Map<String, Object>> relevantChunks = List.of();
String contextBlock = "";
if (genAiService.isConfigured()) {
try {
List<float[]> embeddings = genAiService.embedTexts(List.of(content), "SEARCH_QUERY");
relevantChunks = embeddingRepository.searchSimilar(userId, embeddings.get(0), RAG_TOP_K);
if (!relevantChunks.isEmpty()) {
contextBlock = relevantChunks.stream()
.map(chunk -> {
String title = (String) chunk.get("TITLE");
String chunkContent = (String) chunk.get("CONTENT");
String source = (String) chunk.get("SOURCE_URL");
String header = title != null ? "[" + title + "]" : "[Untitled]";
if (source != null) header += " (" + source + ")";
return header + "\n" + chunkContent;
})
.collect(Collectors.joining("\n\n---\n\n"));
}
} catch (Exception e) {
log.warn("RAG search failed, proceeding without context", e);
}
}
// 3. 대화 히스토리 조회 (최근 20개)
List<Map<String, Object>> history = chatRepository.getMessages(sessionId);
int historyStart = Math.max(0, history.size() - 20);
List<Map<String, Object>> recentHistory = history.subList(historyStart, history.size());
// 4. LLM 프롬프트 구성
String systemMsg = buildSystemPrompt(contextBlock);
String userMsg = buildUserPrompt(recentHistory, content);
// 5. LLM 호출
String assistantResponse;
try {
assistantResponse = genAiService.chat(systemMsg, userMsg, null);
} catch (Exception e) {
log.error("LLM chat failed", e);
assistantResponse = "죄송합니다, 응답을 생성하는 중 오류가 발생했습니다.";
}
// 6. 응답 저장
String sourceChunksJson = null;
if (!relevantChunks.isEmpty()) {
try {
List<Map<String, Object>> sources = relevantChunks.stream()
.map(c -> Map.of(
"knowledgeItemId", (Object) c.get("KNOWLEDGE_ITEM_ID"),
"title", c.get("TITLE") != null ? c.get("TITLE") : "Untitled",
"chunkIndex", c.get("CHUNK_INDEX"),
"distance", c.get("DISTANCE")
))
.toList();
sourceChunksJson = objectMapper.writeValueAsString(sources);
} catch (Exception e) {
log.warn("Failed to serialize source chunks", e);
}
}
chatRepository.insertMessage(sessionId, "assistant", assistantResponse, sourceChunksJson, null);
// 7. 첫 메시지면 세션 제목 업데이트
if (history.size() <= 1) {
String sessionTitle = content.length() > 50
? content.substring(0, 50) + "..." : content;
chatRepository.updateSessionTitle(sessionId, sessionTitle);
}
chatRepository.touchSession(sessionId);
return Map.of(
"role", (Object) "assistant",
"content", assistantResponse,
"sourceChunks", sourceChunksJson != null ? sourceChunksJson : "[]"
);
}).subscribeOn(Schedulers.boundedElastic());
}
private String buildSystemPrompt(String contextBlock) {
StringBuilder sb = new StringBuilder();
sb.append("You are SUNDOL, a helpful personal knowledge assistant. ");
sb.append("Answer questions based on the user's knowledge base. ");
sb.append("If the context contains relevant information, use it to answer accurately. ");
sb.append("If the context doesn't contain relevant information, say so honestly. ");
sb.append("Respond in the same language the user uses.");
if (!contextBlock.isBlank()) {
sb.append("\n\n--- Knowledge Context ---\n\n");
sb.append(contextBlock);
sb.append("\n\n--- End Context ---");
}
return sb.toString();
}
private String buildUserPrompt(List<Map<String, Object>> history, String currentMessage) {
StringBuilder sb = new StringBuilder();
// 최근 대화 히스토리를 포함하여 맥락 유지 (현재 메시지 제외)
for (int i = 0; i < history.size() - 1; i++) {
Map<String, Object> msg = history.get(i);
String role = (String) msg.get("ROLE");
Object contentObj = msg.get("CONTENT");
String content = contentObj != null ? contentObj.toString() : "";
sb.append(role.equals("user") ? "User: " : "Assistant: ");
sb.append(content).append("\n\n");
}
sb.append(currentMessage);
return sb.toString();
} }
public Mono<Void> deleteSession(String userId, String sessionId) { public Mono<Void> deleteSession(String userId, String sessionId) {
// TODO: Delete chat session and messages return Mono.fromRunnable(() -> chatRepository.deleteSession(userId, sessionId))
return Mono.error(new UnsupportedOperationException("Not implemented yet")); .subscribeOn(Schedulers.boundedElastic()).then();
} }
} }

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; package com.sundol.service;
import com.sundol.dto.HabitRequest; import com.sundol.dto.HabitRequest;
import com.sundol.exception.AppException;
import com.sundol.repository.HabitRepository; import com.sundol.repository.HabitRepository;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -18,32 +21,85 @@ public class HabitService {
} }
public Mono<List<Map<String, Object>>> list(String userId) { public Mono<List<Map<String, Object>>> list(String userId) {
// TODO: List habits for user return Mono.fromCallable(() -> {
return Mono.error(new UnsupportedOperationException("Not implemented yet")); List<Map<String, Object>> habits = habitRepository.list(userId);
for (Map<String, Object> habit : habits) {
String habitId = (String) habit.get("ID");
habit.put("CHECKED_TODAY", habitRepository.hasCheckedInToday(habitId));
}
return habits;
}).subscribeOn(Schedulers.boundedElastic());
} }
public Mono<Map<String, Object>> create(String userId, HabitRequest request) { public Mono<Map<String, Object>> create(String userId, HabitRequest request) {
// TODO: Create habit return Mono.fromCallable(() -> {
return Mono.error(new UnsupportedOperationException("Not implemented yet")); String habitType = request.habitType() != null ? request.habitType() : "DAILY";
String color = request.color() != null ? request.color() : "#6366f1";
String id = habitRepository.insert(
userId, request.name(), request.description(),
habitType, request.targetDays(), color
);
return habitRepository.findById(userId, id);
}).subscribeOn(Schedulers.boundedElastic());
} }
public Mono<Map<String, Object>> update(String userId, String id, Map<String, Object> updates) { public Mono<Map<String, Object>> update(String userId, String id, Map<String, Object> updates) {
// TODO: Update habit return Mono.fromCallable(() -> {
return Mono.error(new UnsupportedOperationException("Not implemented yet")); Map<String, Object> habit = habitRepository.findById(userId, id);
if (habit == null) {
throw new AppException(HttpStatus.NOT_FOUND, "Habit not found");
}
if (updates.containsKey("name")) {
habitRepository.updateName(id, (String) updates.get("name"));
}
return habitRepository.findById(userId, id);
}).subscribeOn(Schedulers.boundedElastic());
} }
public Mono<Void> delete(String userId, String id) { public Mono<Void> delete(String userId, String id) {
// TODO: Delete habit and logs return Mono.fromRunnable(() -> {
return Mono.error(new UnsupportedOperationException("Not implemented yet")); Map<String, Object> habit = habitRepository.findById(userId, id);
if (habit == null) {
throw new AppException(HttpStatus.NOT_FOUND, "Habit not found");
}
habitRepository.delete(userId, id);
}).subscribeOn(Schedulers.boundedElastic()).then();
} }
public Mono<Map<String, Object>> checkin(String userId, String id, String note) { public Mono<Map<String, Object>> checkin(String userId, String id, String note) {
// TODO: Check in for today return Mono.fromCallable(() -> {
return Mono.error(new UnsupportedOperationException("Not implemented yet")); Map<String, Object> habit = habitRepository.findById(userId, id);
if (habit == null) {
throw new AppException(HttpStatus.NOT_FOUND, "Habit not found");
}
if (habitRepository.hasCheckedInToday(id)) {
throw new AppException(HttpStatus.CONFLICT, "Already checked in today");
}
habitRepository.insertLog(id, note);
// 스트릭 갱신
int currentStreak = habitRepository.calculateCurrentStreak(id);
Number bestStreakNum = (Number) habit.get("STREAK_BEST");
int bestStreak = bestStreakNum != null ? bestStreakNum.intValue() : 0;
if (currentStreak > bestStreak) {
bestStreak = currentStreak;
}
habitRepository.updateStreak(id, currentStreak, bestStreak);
Map<String, Object> updated = habitRepository.findById(userId, id);
updated.put("CHECKED_TODAY", true);
return updated;
}).subscribeOn(Schedulers.boundedElastic());
} }
public Mono<List<Map<String, Object>>> getLogs(String userId, String id, String from, String to) { public Mono<List<Map<String, Object>>> getLogs(String userId, String id, String from, String to) {
// TODO: Get habit logs return Mono.fromCallable(() -> {
return Mono.error(new UnsupportedOperationException("Not implemented yet")); Map<String, Object> habit = habitRepository.findById(userId, id);
if (habit == null) {
throw new AppException(HttpStatus.NOT_FOUND, "Habit not found");
}
return habitRepository.getLogs(id, from, to);
}).subscribeOn(Schedulers.boundedElastic());
} }
} }

View File

@@ -0,0 +1,278 @@
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 ChunkingService chunkingService;
private final WebCrawlerService webCrawlerService;
private final OciGenAiService genAiService;
private final ObjectMapper objectMapper;
public IngestPipelineService(
KnowledgeRepository knowledgeRepository,
KnowledgeChunkRepository chunkRepository,
ChunkEmbeddingRepository embeddingRepository,
CategoryRepository categoryRepository,
ChunkingService chunkingService,
WebCrawlerService webCrawlerService,
OciGenAiService genAiService,
ObjectMapper objectMapper) {
this.knowledgeRepository = knowledgeRepository;
this.chunkRepository = chunkRepository;
this.embeddingRepository = embeddingRepository;
this.categoryRepository = categoryRepository;
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;
/**
* 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 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: 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 3: Categorize
knowledgeRepository.updateStatus(knowledgeItemId, "CATEGORIZING");
categorize(knowledgeItemId, (String) item.get("USER_ID"), extractedText, modelId);
// Step 4: 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,14 @@
package com.sundol.service; package com.sundol.service;
import com.sundol.dto.IngestRequest; import com.sundol.dto.IngestRequest;
import com.sundol.exception.AppException;
import com.sundol.repository.CategoryRepository;
import com.sundol.repository.KnowledgeChunkRepository;
import com.sundol.repository.KnowledgeRepository; import com.sundol.repository.KnowledgeRepository;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -12,38 +17,77 @@ import java.util.Map;
public class KnowledgeService { public class KnowledgeService {
private final KnowledgeRepository knowledgeRepository; private final KnowledgeRepository knowledgeRepository;
private final KnowledgeChunkRepository chunkRepository;
private final CategoryRepository categoryRepository;
private final IngestPipelineService pipelineService;
public KnowledgeService(KnowledgeRepository knowledgeRepository) { public KnowledgeService(
KnowledgeRepository knowledgeRepository,
KnowledgeChunkRepository chunkRepository,
CategoryRepository categoryRepository,
IngestPipelineService pipelineService) {
this.knowledgeRepository = knowledgeRepository; this.knowledgeRepository = knowledgeRepository;
this.chunkRepository = chunkRepository;
this.categoryRepository = categoryRepository;
this.pipelineService = pipelineService;
} }
public Mono<List<Map<String, Object>>> list(String userId, String type, String status, String tag, String search) { public Mono<List<Map<String, Object>>> list(String userId, String type, String status, String tag, String search) {
// TODO: Query knowledge items with filters return Mono.fromCallable(() -> knowledgeRepository.list(userId, type, status, search))
return Mono.error(new UnsupportedOperationException("Not implemented yet")); .subscribeOn(Schedulers.boundedElastic());
} }
public Mono<Map<String, Object>> ingest(String userId, IngestRequest request) { public Mono<Map<String, Object>> ingest(String userId, IngestRequest request) {
// TODO: Create knowledge item, trigger async ingest pipeline return Mono.fromCallable(() -> {
return Mono.error(new UnsupportedOperationException("Not implemented yet")); String id = knowledgeRepository.insert(
userId, request.type(), request.title(), request.url(), request.rawText()
);
// Trigger async pipeline
pipelineService.runPipeline(id, request.modelId());
return Map.of(
"id", (Object) id,
"status", "PENDING"
);
}).subscribeOn(Schedulers.boundedElastic());
} }
public Mono<Map<String, Object>> getById(String userId, String id) { public Mono<Map<String, Object>> getById(String userId, String id) {
// TODO: Get knowledge item by ID return Mono.fromCallable(() -> {
return Mono.error(new UnsupportedOperationException("Not implemented yet")); Map<String, Object> item = knowledgeRepository.findById(userId, id);
if (item == null) {
throw new AppException(HttpStatus.NOT_FOUND, "Knowledge item not found");
}
item.put("CATEGORIES", categoryRepository.findByKnowledgeItemId(id));
return item;
}).subscribeOn(Schedulers.boundedElastic());
} }
public Mono<Map<String, Object>> update(String userId, String id, Map<String, Object> updates) { public Mono<Map<String, Object>> update(String userId, String id, Map<String, Object> updates) {
// TODO: Update knowledge item fields return Mono.fromCallable(() -> {
return Mono.error(new UnsupportedOperationException("Not implemented yet")); Map<String, Object> item = knowledgeRepository.findById(userId, id);
if (item == null) {
throw new AppException(HttpStatus.NOT_FOUND, "Knowledge item not found");
}
if (updates.containsKey("title")) {
knowledgeRepository.updateTitle(id, (String) updates.get("title"));
}
return knowledgeRepository.findById(userId, id);
}).subscribeOn(Schedulers.boundedElastic());
} }
public Mono<Void> delete(String userId, String id) { public Mono<Void> delete(String userId, String id) {
// TODO: Delete knowledge item and all chunks return Mono.fromRunnable(() -> knowledgeRepository.delete(userId, id))
return Mono.error(new UnsupportedOperationException("Not implemented yet")); .subscribeOn(Schedulers.boundedElastic()).then();
} }
public Mono<List<Map<String, Object>>> getChunks(String userId, String id) { public Mono<List<Map<String, Object>>> getChunks(String userId, String id) {
// TODO: List chunks for knowledge item return Mono.fromCallable(() -> {
return Mono.error(new UnsupportedOperationException("Not implemented yet")); Map<String, Object> item = knowledgeRepository.findById(userId, id);
if (item == null) {
throw new AppException(HttpStatus.NOT_FOUND, "Knowledge item not found");
}
return chunkRepository.findByKnowledgeItemId(id);
}).subscribeOn(Schedulers.boundedElastic());
} }
} }

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

@@ -1,8 +1,11 @@
package com.sundol.service; package com.sundol.service;
import com.sundol.repository.KnowledgeChunkRepository; import com.sundol.repository.ChunkEmbeddingRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -10,14 +13,24 @@ import java.util.Map;
@Service @Service
public class SearchService { public class SearchService {
private final KnowledgeChunkRepository chunkRepository; private static final Logger log = LoggerFactory.getLogger(SearchService.class);
public SearchService(KnowledgeChunkRepository chunkRepository) { private final ChunkEmbeddingRepository embeddingRepository;
this.chunkRepository = chunkRepository; private final OciGenAiService genAiService;
public SearchService(ChunkEmbeddingRepository embeddingRepository, OciGenAiService genAiService) {
this.embeddingRepository = embeddingRepository;
this.genAiService = genAiService;
} }
/**
* 시맨틱 검색: 쿼리를 임베딩 → VECTOR_DISTANCE로 유사 청크 검색
*/
public Mono<List<Map<String, Object>>> search(String userId, String query, int topK) { public Mono<List<Map<String, Object>>> search(String userId, String query, int topK) {
// TODO: Embed query via OCI GenAI, VECTOR_DISTANCE search return Mono.fromCallable(() -> {
return Mono.error(new UnsupportedOperationException("Not implemented yet")); List<float[]> embeddings = genAiService.embedTexts(List.of(query), "SEARCH_QUERY");
float[] queryVector = embeddings.get(0);
return embeddingRepository.searchSimilar(userId, queryVector, topK);
}).subscribeOn(Schedulers.boundedElastic());
} }
} }

View File

@@ -1,8 +1,17 @@
package com.sundol.service; package com.sundol.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sundol.exception.AppException;
import com.sundol.repository.KnowledgeChunkRepository;
import com.sundol.repository.KnowledgeRepository;
import com.sundol.repository.StudyCardRepository; import com.sundol.repository.StudyCardRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -10,29 +19,137 @@ import java.util.Map;
@Service @Service
public class StudyCardService { public class StudyCardService {
private final StudyCardRepository studyCardRepository; private static final Logger log = LoggerFactory.getLogger(StudyCardService.class);
public StudyCardService(StudyCardRepository studyCardRepository) { private final StudyCardRepository studyCardRepository;
private final KnowledgeRepository knowledgeRepository;
private final KnowledgeChunkRepository chunkRepository;
private final OciGenAiService genAiService;
private final ObjectMapper objectMapper;
public StudyCardService(
StudyCardRepository studyCardRepository,
KnowledgeRepository knowledgeRepository,
KnowledgeChunkRepository chunkRepository,
OciGenAiService genAiService,
ObjectMapper objectMapper) {
this.studyCardRepository = studyCardRepository; this.studyCardRepository = studyCardRepository;
this.knowledgeRepository = knowledgeRepository;
this.chunkRepository = chunkRepository;
this.genAiService = genAiService;
this.objectMapper = objectMapper;
} }
public Mono<List<Map<String, Object>>> getDueCards(String userId) { public Mono<List<Map<String, Object>>> getDueCards(String userId) {
// TODO: Get cards due for review today return Mono.fromCallable(() -> studyCardRepository.getDueCards(userId))
return Mono.error(new UnsupportedOperationException("Not implemented yet")); .subscribeOn(Schedulers.boundedElastic());
} }
public Mono<List<Map<String, Object>>> getByKnowledgeItem(String userId, String knowledgeItemId) { public Mono<List<Map<String, Object>>> getByKnowledgeItem(String userId, String knowledgeItemId) {
// TODO: Get cards for a specific knowledge item return Mono.fromCallable(() -> studyCardRepository.getByKnowledgeItem(userId, knowledgeItemId))
return Mono.error(new UnsupportedOperationException("Not implemented yet")); .subscribeOn(Schedulers.boundedElastic());
} }
/**
* Knowledge item의 청크들을 기반으로 LLM이 Q&A 카드를 자동 생성.
*/
public Mono<Map<String, Object>> generate(String userId, String knowledgeItemId) { public Mono<Map<String, Object>> generate(String userId, String knowledgeItemId) {
// TODO: Trigger AI card generation from knowledge item return Mono.fromCallable(() -> {
return Mono.error(new UnsupportedOperationException("Not implemented yet")); Map<String, Object> item = knowledgeRepository.findById(userId, knowledgeItemId);
if (item == null) {
throw new AppException(HttpStatus.NOT_FOUND, "Knowledge item not found");
}
if (!"READY".equals(item.get("STATUS"))) {
throw new AppException(HttpStatus.BAD_REQUEST, "Knowledge item is not ready yet");
}
List<Map<String, Object>> chunks = chunkRepository.findByKnowledgeItemId(knowledgeItemId);
if (chunks.isEmpty()) {
throw new AppException(HttpStatus.BAD_REQUEST, "No chunks available");
}
// 청크 내용을 합쳐서 LLM에 전달 (최대 4000자)
StringBuilder content = new StringBuilder();
for (Map<String, Object> chunk : chunks) {
Object c = chunk.get("CONTENT");
if (c != null) {
content.append(c.toString()).append("\n\n");
}
if (content.length() > 4000) break;
}
String systemMsg =
"You are a study card generator. Create flashcards from the given content. " +
"Each card has a 'front' (question) and 'back' (answer). " +
"Generate 3-5 cards that test key concepts. " +
"Use the same language as the content. " +
"Respond with ONLY a JSON array: [{\"front\":\"question\",\"back\":\"answer\"},...]. " +
"No markdown, no explanation.";
String userMsg = "Generate study cards from this content:\n\n" + content;
String response = genAiService.chat(systemMsg, userMsg, null);
response = response.replaceAll("```json\\s*", "").replaceAll("```\\s*", "").strip();
List<Map<String, String>> cards = objectMapper.readValue(response, new TypeReference<>() {});
int created = 0;
for (Map<String, String> card : cards) {
String front = card.get("front");
String back = card.get("back");
if (front != null && back != null && !front.isBlank() && !back.isBlank()) {
studyCardRepository.insert(userId, knowledgeItemId, front, back);
created++;
}
}
log.info("Generated {} study cards for knowledge item {}", created, knowledgeItemId);
return Map.of("generated", (Object) created, "knowledgeItemId", knowledgeItemId);
}).subscribeOn(Schedulers.boundedElastic());
} }
/**
* SM-2 알고리즘으로 카드 복습 결과 처리.
* rating: 0(완전 모름) ~ 5(완벽)
*/
public Mono<Map<String, Object>> review(String userId, String id, int rating) { public Mono<Map<String, Object>> review(String userId, String id, int rating) {
// TODO: Apply SM-2 algorithm and update card return Mono.fromCallable(() -> {
return Mono.error(new UnsupportedOperationException("Not implemented yet")); Map<String, Object> card = studyCardRepository.findById(userId, id);
if (card == null) {
throw new AppException(HttpStatus.NOT_FOUND, "Study card not found");
}
Number efNum = (Number) card.get("EASE_FACTOR");
Number ivNum = (Number) card.get("INTERVAL_DAYS");
Number repNum = (Number) card.get("REPETITIONS");
double easeFactor = efNum != null ? efNum.doubleValue() : 2.5;
int intervalDays = ivNum != null ? ivNum.intValue() : 0;
int repetitions = repNum != null ? repNum.intValue() : 0;
// SM-2 알고리즘
if (rating < 3) {
// 실패: 처음부터 다시
repetitions = 0;
intervalDays = 1;
} else {
// 성공
repetitions++;
if (repetitions == 1) {
intervalDays = 1;
} else if (repetitions == 2) {
intervalDays = 6;
} else {
intervalDays = (int) Math.round(intervalDays * easeFactor);
}
}
// Ease Factor 갱신
easeFactor = easeFactor + (0.1 - (5 - rating) * (0.08 + (5 - rating) * 0.02));
if (easeFactor < 1.3) easeFactor = 1.3;
studyCardRepository.updateSm2(id, easeFactor, intervalDays, repetitions, intervalDays);
return studyCardRepository.findById(userId, id);
}).subscribeOn(Schedulers.boundedElastic());
} }
} }

View File

@@ -1,9 +1,12 @@
package com.sundol.service; package com.sundol.service;
import com.sundol.dto.TagRequest; import com.sundol.dto.TagRequest;
import com.sundol.exception.AppException;
import com.sundol.repository.TagRepository; import com.sundol.repository.TagRepository;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -18,22 +21,41 @@ public class TagService {
} }
public Mono<List<Map<String, Object>>> list(String userId) { public Mono<List<Map<String, Object>>> list(String userId) {
// TODO: List tags for user return Mono.fromCallable(() -> tagRepository.list(userId))
return Mono.error(new UnsupportedOperationException("Not implemented yet")); .subscribeOn(Schedulers.boundedElastic());
} }
public Mono<Map<String, Object>> create(String userId, TagRequest request) { public Mono<Map<String, Object>> create(String userId, TagRequest request) {
// TODO: Create tag return Mono.fromCallable(() -> {
return Mono.error(new UnsupportedOperationException("Not implemented yet")); String color = request.color() != null ? request.color() : "#6366f1";
String id = tagRepository.insert(userId, request.name(), color);
return tagRepository.findById(userId, id);
}).subscribeOn(Schedulers.boundedElastic());
} }
public Mono<Map<String, Object>> update(String userId, String id, TagRequest request) { public Mono<Map<String, Object>> update(String userId, String id, TagRequest request) {
// TODO: Update tag return Mono.fromCallable(() -> {
return Mono.error(new UnsupportedOperationException("Not implemented yet")); Map<String, Object> tag = tagRepository.findById(userId, id);
if (tag == null) {
throw new AppException(HttpStatus.NOT_FOUND, "Tag not found");
}
if (request.name() != null) {
tagRepository.updateName(id, request.name());
}
if (request.color() != null) {
tagRepository.updateColor(id, request.color());
}
return tagRepository.findById(userId, id);
}).subscribeOn(Schedulers.boundedElastic());
} }
public Mono<Void> delete(String userId, String id) { public Mono<Void> delete(String userId, String id) {
// TODO: Delete tag and remove from items return Mono.fromRunnable(() -> {
return Mono.error(new UnsupportedOperationException("Not implemented yet")); Map<String, Object> tag = tagRepository.findById(userId, id);
if (tag == null) {
throw new AppException(HttpStatus.NOT_FOUND, "Tag not found");
}
tagRepository.delete(userId, id);
}).subscribeOn(Schedulers.boundedElastic()).then();
} }
} }

View File

@@ -1,9 +1,12 @@
package com.sundol.service; package com.sundol.service;
import com.sundol.dto.TodoRequest; import com.sundol.dto.TodoRequest;
import com.sundol.exception.AppException;
import com.sundol.repository.TodoRepository; import com.sundol.repository.TodoRepository;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -18,27 +21,72 @@ public class TodoService {
} }
public Mono<List<Map<String, Object>>> list(String userId, String status, String priority, String dueDate) { public Mono<List<Map<String, Object>>> list(String userId, String status, String priority, String dueDate) {
// TODO: List todos with filters return Mono.fromCallable(() -> {
return Mono.error(new UnsupportedOperationException("Not implemented yet")); List<Map<String, Object>> todos = todoRepository.list(userId, status, priority, dueDate);
// 각 todo에 서브태스크 개수 포함
for (Map<String, Object> todo : todos) {
String todoId = (String) todo.get("ID");
List<Map<String, Object>> subtasks = todoRepository.findSubtasks(userId, todoId);
todo.put("SUBTASK_COUNT", subtasks.size());
long doneCount = subtasks.stream()
.filter(s -> "DONE".equals(s.get("STATUS")))
.count();
todo.put("SUBTASK_DONE_COUNT", doneCount);
}
return todos;
}).subscribeOn(Schedulers.boundedElastic());
} }
public Mono<Map<String, Object>> create(String userId, TodoRequest request) { public Mono<Map<String, Object>> create(String userId, TodoRequest request) {
// TODO: Create todo return Mono.fromCallable(() -> {
return Mono.error(new UnsupportedOperationException("Not implemented yet")); String priority = request.priority() != null ? request.priority() : "MEDIUM";
String id = todoRepository.insert(
userId, request.title(), request.description(),
priority, request.dueDate(), request.parentId()
);
return todoRepository.findById(userId, id);
}).subscribeOn(Schedulers.boundedElastic());
} }
public Mono<Map<String, Object>> update(String userId, String id, Map<String, Object> updates) { public Mono<Map<String, Object>> update(String userId, String id, Map<String, Object> updates) {
// TODO: Update todo fields return Mono.fromCallable(() -> {
return Mono.error(new UnsupportedOperationException("Not implemented yet")); Map<String, Object> todo = todoRepository.findById(userId, id);
if (todo == null) {
throw new AppException(HttpStatus.NOT_FOUND, "Todo not found");
}
if (updates.containsKey("status")) {
todoRepository.updateStatus(id, (String) updates.get("status"));
}
if (updates.containsKey("title")) {
todoRepository.updateTitle(id, (String) updates.get("title"));
}
if (updates.containsKey("priority")) {
todoRepository.updatePriority(id, (String) updates.get("priority"));
}
if (updates.containsKey("dueDate")) {
todoRepository.updateDueDate(id, (String) updates.get("dueDate"));
}
return todoRepository.findById(userId, id);
}).subscribeOn(Schedulers.boundedElastic());
} }
public Mono<Void> delete(String userId, String id) { public Mono<Void> delete(String userId, String id) {
// TODO: Delete todo and subtasks return Mono.fromRunnable(() -> {
return Mono.error(new UnsupportedOperationException("Not implemented yet")); Map<String, Object> todo = todoRepository.findById(userId, id);
if (todo == null) {
throw new AppException(HttpStatus.NOT_FOUND, "Todo not found");
}
todoRepository.delete(userId, id);
}).subscribeOn(Schedulers.boundedElastic()).then();
} }
public Mono<List<Map<String, Object>>> getSubtasks(String userId, String id) { public Mono<List<Map<String, Object>>> getSubtasks(String userId, String id) {
// TODO: List subtasks for todo return Mono.fromCallable(() -> {
return Mono.error(new UnsupportedOperationException("Not implemented yet")); Map<String, Object> todo = todoRepository.findById(userId, id);
if (todo == null) {
throw new AppException(HttpStatus.NOT_FOUND, "Todo not found");
}
return todoRepository.findSubtasks(userId, id);
}).subscribeOn(Schedulers.boundedElastic());
} }
} }

View File

@@ -0,0 +1,223 @@
package com.sundol.service;
import com.microsoft.playwright.Browser;
import com.microsoft.playwright.BrowserType;
import com.microsoft.playwright.Page;
import com.microsoft.playwright.Playwright;
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;
@Value("${jina.reader.api-key:}")
private String jinaApiKey;
public WebCrawlerService() {
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 headless browser (최후의 수단)
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);
try (Playwright playwright = Playwright.create()) {
BrowserType.LaunchOptions launchOptions = new BrowserType.LaunchOptions()
.setHeadless(true)
.setArgs(java.util.List.of(
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
"--disable-gpu"
));
try (Browser browser = playwright.chromium().launch(launchOptions)) {
Browser.NewContextOptions contextOptions = new Browser.NewContextOptions()
.setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36");
var context = browser.newContext(contextOptions);
Page page = context.newPage();
page.navigate(url, new Page.NavigateOptions()
.setTimeout(30_000)
.setWaitUntil(com.microsoft.playwright.options.WaitUntilState.NETWORKIDLE));
// 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;
}
} catch (IOException e) {
throw e;
} catch (Exception e) {
throw new IOException("Playwright failed for " + url + ": " + e.getMessage(), e);
}
}
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로 제목 추출
try (Playwright playwright = Playwright.create()) {
try (Browser browser = playwright.chromium().launch(
new BrowserType.LaunchOptions().setHeadless(true)
.setArgs(java.util.List.of("--no-sandbox", "--disable-setuid-sandbox")))) {
Page page = browser.newPage();
page.navigate(url, new Page.NavigateOptions().setTimeout(30_000));
return page.title();
}
} catch (Exception e3) {
throw new IOException("All title extraction methods failed for: " + url, e3);
}
}
}
}
}

View File

@@ -0,0 +1,295 @@
package com.sundol.service;
import com.microsoft.playwright.*;
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.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
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();
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;
String html;
try (Playwright playwright = Playwright.create()) {
BrowserType.LaunchOptions launchOptions = new BrowserType.LaunchOptions()
.setHeadless(false)
.setArgs(List.of(
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage"
));
try (Browser browser = playwright.chromium().launch(launchOptions)) {
BrowserContext context = browser.newContext(new Browser.NewContextOptions()
.setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36")
.setLocale("ko-KR"));
// YouTube 쿠키 로딩 (봇 차단 우회)
loadCookies(context);
Page page = context.newPage();
page.navigate(watchUrl, new Page.NavigateOptions()
.setTimeout(30_000)
.setWaitUntil(WaitUntilState.NETWORKIDLE));
html = page.content();
log.info("Playwright fetched YouTube page: {} chars", html.length());
context.close();
}
} catch (Exception e) {
throw new IOException("YouTube 페이지를 가져올 수 없습니다 (Playwright): " + e.getMessage(), e);
}
// captionTracks JSON 추출
Matcher captionMatcher = CAPTION_TRACK_PATTERN.matcher(html);
if (!captionMatcher.find()) {
throw new IOException("이 영상에는 자막(caption)이 없습니다.");
}
String captionTracksJson = captionMatcher.group(1);
String captionUrl = selectCaptionUrl(captionTracksJson);
if (captionUrl == null) {
throw new IOException("자막 트랙 URL을 추출할 수 없습니다.");
}
captionUrl = captionUrl.replace("\\u0026", "&");
log.info("Fetching caption XML from: {}", captionUrl);
// 자막 XML 가져오기
log.info("Attempting to fetch caption XML...");
String xml;
try {
var conn = (java.net.HttpURLConnection) new java.net.URI(captionUrl).toURL().openConnection();
conn.setRequestProperty("User-Agent", "Mozilla/5.0");
conn.setConnectTimeout(15_000);
conn.setReadTimeout(15_000);
int responseCode = conn.getResponseCode();
log.info("Caption XML response code: {}", responseCode);
if (responseCode != 200) {
String errorBody = new String(conn.getErrorStream().readAllBytes(), StandardCharsets.UTF_8);
log.error("Caption XML error response: {}", errorBody);
throw new IOException("자막 XML 응답 코드: " + responseCode);
}
xml = new String(conn.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
log.info("Caption XML fetched: {} chars", xml.length());
} catch (Exception e) {
log.error("Failed to fetch caption XML: {}", e.getMessage(), e);
throw new IOException("자막 XML을 가져올 수 없습니다: " + e.getMessage(), e);
}
String transcript = parseTranscriptXml(xml);
log.info("Parsed transcript: {} chars (blank={})", transcript.length(), transcript.isBlank());
if (transcript.isBlank()) {
log.error("Transcript XML content (first 500 chars): {}", xml.substring(0, Math.min(500, xml.length())));
throw new IOException("자막 텍스트를 파싱할 수 없습니다.");
}
log.info("Successfully fetched transcript via Playwright: {} chars", transcript.length());
return transcript;
}
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 void loadCookies(BrowserContext context) {
Path cookieFile = Path.of(System.getProperty("user.dir"), "cookies.txt");
if (!Files.exists(cookieFile)) {
log.warn("cookies.txt not found at: {}", cookieFile);
return;
}
try {
List<String> lines = Files.readAllLines(cookieFile);
List<com.microsoft.playwright.options.Cookie> cookies = new ArrayList<>();
for (String line : lines) {
if (line.startsWith("#") || line.isBlank()) continue;
String[] parts = line.split("\t");
if (parts.length < 7) continue;
String domain = parts[0];
if (!domain.contains("youtube") && !domain.contains("google")) continue;
cookies.add(new com.microsoft.playwright.options.Cookie(parts[5], parts[6])
.setDomain(domain)
.setPath(parts[2])
.setSecure("TRUE".equalsIgnoreCase(parts[3]))
.setHttpOnly(false));
}
if (!cookies.isEmpty()) {
context.addCookies(cookies);
log.info("Loaded {} YouTube cookies", cookies.size());
}
} catch (Exception e) {
log.warn("Failed to load cookies: {}", e.getMessage());
}
}
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} access-token-expiry: ${JWT_ACCESS_TOKEN_EXPIRY:900000}
refresh-token-expiry: ${JWT_REFRESH_TOKEN_EXPIRY:604800000} refresh-token-expiry: ${JWT_REFRESH_TOKEN_EXPIRY:604800000}
google:
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
redirect-uri: ${GOOGLE_REDIRECT_URI:https://sundol.cloud-handson.com/login/callback}
cors: cors:
origin: ${CORS_ORIGIN:http://localhost:3000} origin: ${CORS_ORIGIN:http://localhost:3000}
oci: oci:
compartment-id: ${OCI_COMPARTMENT_ID:} compartment-id: ${OCI_COMPARTMENT_ID:}
region: ${OCI_REGION:ap-seoul-1} region: ${OCI_REGION:ap-seoul-1}
genai:
api-key: ${OCI_GENAI_API_KEY:}
compartment: ${OCI_GENAI_COMPARTMENT:}
model: ${OCI_GENAI_MODEL:google.gemini-2.5-flash}
base-url: ${OCI_GENAI_BASE_URL:https://inference.generativeai.us-chicago-1.oci.oraclecloud.com/20231130/actions}
jina:
reader:
api-key: ${JINA_READER_API_KEY:}
logging: logging:
level: level:

41
sundol-frontend/build.sh Executable file
View File

@@ -0,0 +1,41 @@
#!/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] 심볼릭 링크 생성 ==="
STATIC_SRC="$SCRIPT_DIR/.next/static"
STATIC_DST="$SCRIPT_DIR/.next/standalone/.next/static"
if [ -L "$STATIC_DST" ] || [ -e "$STATIC_DST" ]; then
rm -rf "$STATIC_DST"
fi
ln -s "$STATIC_SRC" "$STATIC_DST"
echo "링크 생성 완료: $STATIC_DST -> $STATIC_SRC"
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

@@ -1,16 +1,297 @@
"use client"; "use client";
import { useEffect, useState, useRef } from "react";
import AuthGuard from "@/components/auth-guard"; import AuthGuard from "@/components/auth-guard";
import NavBar from "@/components/nav-bar"; import NavBar from "@/components/nav-bar";
import { useApi } from "@/lib/use-api";
interface Session {
ID: string;
TITLE: string;
CREATED_AT: string;
UPDATED_AT: string;
}
interface Message {
ID: string;
ROLE: string;
CONTENT: string;
SOURCE_CHUNKS: string | null;
CREATED_AT: string;
}
interface SourceChunk {
knowledgeItemId: string;
title: string;
chunkIndex: number;
distance: number;
}
export default function ChatPage() { export default function ChatPage() {
const { request } = useApi();
const [sessions, setSessions] = useState<Session[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState("");
const [sending, setSending] = useState(false);
const [loadingSessions, setLoadingSessions] = useState(true);
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
// Load sessions
useEffect(() => {
request<Session[]>({ method: "GET", url: "/api/chat/sessions" })
.then(setSessions)
.catch((err) => console.error("Failed to load sessions:", err))
.finally(() => setLoadingSessions(false));
}, []);
// Load messages when session changes
useEffect(() => {
if (!activeSessionId) {
setMessages([]);
return;
}
request<Message[]>({ method: "GET", url: `/api/chat/sessions/${activeSessionId}/messages` })
.then(setMessages)
.catch((err) => console.error("Failed to load messages:", err));
}, [activeSessionId]);
const handleNewSession = async () => {
try {
const session = await request<Session>({ method: "POST", url: "/api/chat/sessions" });
setSessions((prev) => [session, ...prev]);
setActiveSessionId(session.ID);
} catch (err) {
console.error("Failed to create session:", err);
}
};
const handleDeleteSession = async (sessionId: string) => {
try {
await request({ method: "DELETE", url: `/api/chat/sessions/${sessionId}` });
setSessions((prev) => prev.filter((s) => s.ID !== sessionId));
if (activeSessionId === sessionId) {
setActiveSessionId(null);
setMessages([]);
}
} catch (err) {
console.error("Failed to delete session:", err);
}
};
const handleSend = async () => {
if (!input.trim() || sending || !activeSessionId) return;
const userMessage = input.trim();
setInput("");
setSending(true);
// Optimistic UI: add user message immediately
const tempUserMsg: Message = {
ID: "temp-user",
ROLE: "user",
CONTENT: userMessage,
SOURCE_CHUNKS: null,
CREATED_AT: new Date().toISOString(),
};
setMessages((prev) => [...prev, tempUserMsg]);
try {
const response = await request<{ role: string; content: string; sourceChunks: string }>({
method: "POST",
url: `/api/chat/sessions/${activeSessionId}/messages`,
data: { content: userMessage },
});
const assistantMsg: Message = {
ID: "temp-assistant-" + Date.now(),
ROLE: "assistant",
CONTENT: response.content,
SOURCE_CHUNKS: response.sourceChunks,
CREATED_AT: new Date().toISOString(),
};
setMessages((prev) => [...prev, assistantMsg]);
// Refresh sessions for updated title
request<Session[]>({ method: "GET", url: "/api/chat/sessions" })
.then(setSessions)
.catch(() => {});
} catch (err) {
console.error("Failed to send message:", err);
const errorMsg: Message = {
ID: "temp-error",
ROLE: "assistant",
CONTENT: "메시지 전송에 실패했습니다.",
SOURCE_CHUNKS: null,
CREATED_AT: new Date().toISOString(),
};
setMessages((prev) => [...prev, errorMsg]);
} finally {
setSending(false);
}
};
const parseSourceChunks = (json: string | null): SourceChunk[] => {
if (!json) return [];
try {
return JSON.parse(json);
} catch {
return [];
}
};
return ( return (
<AuthGuard> <AuthGuard>
<NavBar /> <NavBar />
<main className="max-w-7xl mx-auto px-4 py-8"> <main className="max-w-7xl mx-auto px-4 py-4 h-[calc(100vh-64px)] flex gap-4">
<h1 className="text-2xl font-bold mb-6">AI Chat</h1> {/* Sidebar: Sessions */}
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)] min-h-[60vh] flex items-center justify-center"> <div className="w-64 flex-shrink-0 flex flex-col">
<p className="text-[var(--color-text-muted)]">Start a new conversation to ask questions about your knowledge base.</p> <button
onClick={handleNewSession}
className="w-full px-4 py-2 mb-3 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg text-sm transition-colors"
>
+ New Chat
</button>
<div className="flex-1 overflow-y-auto space-y-1">
{loadingSessions ? (
<p className="text-sm text-[var(--color-text-muted)] px-2">Loading...</p>
) : sessions.length === 0 ? (
<p className="text-sm text-[var(--color-text-muted)] px-2">No conversations yet</p>
) : (
sessions.map((s) => (
<div
key={s.ID}
className={`group flex items-center rounded-lg px-3 py-2 text-sm cursor-pointer transition-colors ${
activeSessionId === s.ID
? "bg-[var(--color-primary)]/20 text-[var(--color-primary)]"
: "hover:bg-[var(--color-bg-hover)] text-[var(--color-text-muted)]"
}`}
onClick={() => setActiveSessionId(s.ID)}
>
<span className="flex-1 truncate">{s.TITLE || "New Chat"}</span>
<button
onClick={(e) => {
e.stopPropagation();
handleDeleteSession(s.ID);
}}
className="hidden group-hover:block text-red-400 hover:text-red-300 ml-2 text-xs"
>
</button>
</div>
))
)}
</div>
</div>
{/* Main Chat Area */}
<div className="flex-1 flex flex-col min-w-0">
{!activeSessionId ? (
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<h2 className="text-xl font-bold mb-2">AI Chat</h2>
<p className="text-[var(--color-text-muted)] mb-4">
Knowledge base를 .
</p>
<button
onClick={handleNewSession}
className="px-6 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors"
>
Start a new chat
</button>
</div>
</div>
) : (
<>
{/* Messages */}
<div className="flex-1 overflow-y-auto space-y-4 py-4">
{messages.length === 0 && (
<p className="text-center text-[var(--color-text-muted)] mt-20">
Knowledge base에 .
</p>
)}
{messages.map((msg) => (
<div
key={msg.ID}
className={`flex ${msg.ROLE === "user" ? "justify-end" : "justify-start"}`}
>
<div
className={`max-w-[75%] rounded-xl px-4 py-3 ${
msg.ROLE === "user"
? "bg-[var(--color-primary)] text-white"
: "bg-[var(--color-bg-card)] border border-[var(--color-border)]"
}`}
>
<p className="whitespace-pre-wrap text-sm">{msg.CONTENT}</p>
{/* Source chunks */}
{msg.ROLE === "assistant" && (() => {
const sources = parseSourceChunks(msg.SOURCE_CHUNKS);
if (sources.length === 0) return null;
return (
<div className="mt-2 pt-2 border-t border-[var(--color-border)]">
<p className="text-xs text-[var(--color-text-muted)] mb-1">:</p>
<div className="flex flex-wrap gap-1">
{sources.map((src, i) => (
<a
key={i}
href={`/knowledge/${src.knowledgeItemId}`}
className="text-xs px-2 py-0.5 rounded bg-[var(--color-bg-hover)] text-[var(--color-text-muted)] hover:text-[var(--color-primary)]"
>
{src.title}
</a>
))}
</div>
</div>
);
})()}
</div>
</div>
))}
{sending && (
<div className="flex justify-start">
<div className="bg-[var(--color-bg-card)] border border-[var(--color-border)] rounded-xl px-4 py-3">
<div className="flex gap-1">
<div className="w-2 h-2 bg-[var(--color-text-muted)] rounded-full animate-bounce" style={{ animationDelay: "0ms" }} />
<div className="w-2 h-2 bg-[var(--color-text-muted)] rounded-full animate-bounce" style={{ animationDelay: "150ms" }} />
<div className="w-2 h-2 bg-[var(--color-text-muted)] rounded-full animate-bounce" style={{ animationDelay: "300ms" }} />
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<div className="py-3">
<div className="flex gap-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && !e.shiftKey && handleSend()}
placeholder="메시지를 입력하세요..."
disabled={sending}
className="flex-1 px-4 py-3 rounded-xl bg-[var(--color-bg-card)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none disabled:opacity-50"
/>
<button
onClick={handleSend}
disabled={!input.trim() || sending}
className="px-6 py-3 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] disabled:opacity-40 disabled:cursor-not-allowed rounded-xl transition-colors"
>
</button>
</div>
</div>
</>
)}
</div> </div>
</main> </main>
</AuthGuard> </AuthGuard>

View File

@@ -1,33 +1,81 @@
"use client"; "use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import AuthGuard from "@/components/auth-guard"; import AuthGuard from "@/components/auth-guard";
import NavBar from "@/components/nav-bar"; import NavBar from "@/components/nav-bar";
import { useApi } from "@/lib/use-api";
interface DashData {
knowledgeCount: number;
dueCards: number;
activeTodos: number;
habitStreaks: number;
chatSessions: number;
tags: number;
}
export default function DashboardPage() { export default function DashboardPage() {
const { request } = useApi();
const [data, setData] = useState<DashData | null>(null);
useEffect(() => {
Promise.all([
request<unknown[]>({ method: "GET", url: "/api/knowledge" }).catch(() => []),
request<unknown[]>({ method: "GET", url: "/api/study-cards/due" }).catch(() => []),
request<unknown[]>({ method: "GET", url: "/api/todos?status=PENDING" }).catch(() => []),
request<{ STREAK_CURRENT?: number }[]>({ method: "GET", url: "/api/habits" }).catch(() => []),
request<unknown[]>({ method: "GET", url: "/api/chat/sessions" }).catch(() => []),
request<unknown[]>({ method: "GET", url: "/api/tags" }).catch(() => []),
]).then(([knowledge, dueCards, todos, habits, sessions, tags]) => {
const activeStreaks = (habits as { STREAK_CURRENT?: number }[]).filter(
(h) => h.STREAK_CURRENT && h.STREAK_CURRENT > 0
).length;
setData({
knowledgeCount: knowledge.length,
dueCards: dueCards.length,
activeTodos: todos.length,
habitStreaks: activeStreaks,
chatSessions: sessions.length,
tags: tags.length,
});
});
}, []);
const cards: { title: string; value: string; description: string; href: string; color: string }[] = data
? [
{ title: "Knowledge Items", value: String(data.knowledgeCount), description: "수집된 항목", href: "/knowledge", color: "text-blue-400" },
{ title: "Due Study Cards", value: String(data.dueCards), description: "복습 대기 카드", href: "/study", color: "text-purple-400" },
{ title: "Active Todos", value: String(data.activeTodos), description: "진행중인 할 일", href: "/todos", color: "text-yellow-400" },
{ title: "Habit Streaks", value: String(data.habitStreaks), description: "활성 연속 기록", href: "/habits", color: "text-green-400" },
{ title: "Chat Sessions", value: String(data.chatSessions), description: "대화 세션", href: "/chat", color: "text-cyan-400" },
{ title: "Tags", value: String(data.tags), description: "태그 수", href: "#", color: "text-indigo-400" },
]
: [];
return ( return (
<AuthGuard> <AuthGuard>
<NavBar /> <NavBar />
<main className="max-w-7xl mx-auto px-4 py-8"> <main className="max-w-7xl mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-6">Dashboard</h1> <h1 className="text-2xl font-bold mb-6">Dashboard</h1>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> {!data ? (
<DashCard title="Knowledge Items" value="-" description="Total ingested items" /> <p className="text-[var(--color-text-muted)]">Loading...</p>
<DashCard title="Due Study Cards" value="-" description="Cards due for review" /> ) : (
<DashCard title="Active Todos" value="-" description="Pending tasks" /> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<DashCard title="Habit Streaks" value="-" description="Current active streaks" /> {cards.map((card) => (
<DashCard title="Chat Sessions" value="-" description="Active conversations" /> <Link
<DashCard title="Tags" value="-" description="Knowledge categories" /> key={card.title}
</div> href={card.href}
className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)] hover:border-[var(--color-primary)] transition-colors"
>
<h3 className="text-sm text-[var(--color-text-muted)] mb-1">{card.title}</h3>
<p className={`text-3xl font-bold mb-1 ${card.color}`}>{card.value}</p>
<p className="text-sm text-[var(--color-text-muted)]">{card.description}</p>
</Link>
))}
</div>
)}
</main> </main>
</AuthGuard> </AuthGuard>
); );
} }
function DashCard({ title, value, description }: { title: string; value: string; description: string }) {
return (
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)]">
<h3 className="text-sm text-[var(--color-text-muted)] mb-1">{title}</h3>
<p className="text-3xl font-bold mb-1">{value}</p>
<p className="text-sm text-[var(--color-text-muted)]">{description}</p>
</div>
);
}

View File

@@ -1,22 +1,203 @@
"use client"; "use client";
import { useEffect, useState } from "react";
import AuthGuard from "@/components/auth-guard"; import AuthGuard from "@/components/auth-guard";
import NavBar from "@/components/nav-bar"; import NavBar from "@/components/nav-bar";
import { useApi } from "@/lib/use-api";
interface Habit {
ID: string;
NAME: string;
DESCRIPTION: string | null;
HABIT_TYPE: string;
COLOR: string;
STREAK_CURRENT: number;
STREAK_BEST: number;
CHECKED_TODAY: boolean;
}
const COLORS = ["#6366f1", "#ec4899", "#f59e0b", "#10b981", "#3b82f6", "#8b5cf6", "#ef4444"];
export default function HabitsPage() { export default function HabitsPage() {
const { request } = useApi();
const [habits, setHabits] = useState<Habit[]>([]);
const [loading, setLoading] = useState(true);
// Add form
const [showAdd, setShowAdd] = useState(false);
const [newName, setNewName] = useState("");
const [newColor, setNewColor] = useState(COLORS[0]);
const fetchHabits = async () => {
try {
const data = await request<Habit[]>({ method: "GET", url: "/api/habits" });
setHabits(data);
} catch (err) {
console.error("Failed to load habits:", err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchHabits();
}, []);
const handleAdd = async () => {
if (!newName.trim()) return;
try {
await request({
method: "POST",
url: "/api/habits",
data: { name: newName.trim(), habitType: "DAILY", color: newColor },
});
setNewName("");
setShowAdd(false);
fetchHabits();
} catch (err) {
console.error("Failed to create habit:", err);
}
};
const handleCheckin = async (habitId: string) => {
try {
await request({ method: "POST", url: `/api/habits/${habitId}/checkin`, data: {} });
fetchHabits();
} catch (err) {
console.error("Failed to check in:", err);
}
};
const handleDelete = async (habitId: string) => {
if (!confirm("이 습관을 삭제하시겠습니까?")) return;
try {
await request({ method: "DELETE", url: `/api/habits/${habitId}` });
fetchHabits();
} catch (err) {
console.error("Failed to delete habit:", err);
}
};
// 오늘 요일 (월~일 한글)
const today = new Date();
const weekDays = ["일", "월", "화", "수", "목", "금", "토"];
return ( return (
<AuthGuard> <AuthGuard>
<NavBar /> <NavBar />
<main className="max-w-7xl mx-auto px-4 py-8"> <main className="max-w-3xl mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Habits</h1> <div>
<button className="px-4 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors"> <h1 className="text-2xl font-bold">Habits</h1>
<p className="text-sm text-[var(--color-text-muted)]">
{today.toLocaleDateString("ko-KR", { month: "long", day: "numeric", weekday: "long" })}
</p>
</div>
<button
onClick={() => setShowAdd(!showAdd)}
className="px-4 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors text-sm"
>
+ Add Habit + Add Habit
</button> </button>
</div> </div>
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)]">
<p className="text-[var(--color-text-muted)]">No habits tracked yet. Start building good habits.</p> {/* Add form */}
</div> {showAdd && (
<div className="bg-[var(--color-bg-card)] rounded-xl p-4 border border-[var(--color-border)] mb-4 space-y-3">
<input
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
placeholder="습관 이름 (예: 물 2L 마시기)"
autoFocus
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none"
/>
<div className="flex items-center gap-3">
<span className="text-sm text-[var(--color-text-muted)]">:</span>
<div className="flex gap-2">
{COLORS.map((c) => (
<button
key={c}
onClick={() => setNewColor(c)}
className={`w-6 h-6 rounded-full transition-transform ${newColor === c ? "scale-125 ring-2 ring-white" : ""}`}
style={{ backgroundColor: c }}
/>
))}
</div>
<button
onClick={handleAdd}
disabled={!newName.trim()}
className="ml-auto px-4 py-1.5 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] disabled:opacity-40 rounded-lg text-sm transition-colors"
>
</button>
</div>
</div>
)}
{/* Habit list */}
{loading ? (
<p className="text-[var(--color-text-muted)]">Loading...</p>
) : habits.length === 0 ? (
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)]">
<p className="text-[var(--color-text-muted)]"> . .</p>
</div>
) : (
<div className="space-y-3">
{habits.map((habit) => (
<div
key={habit.ID}
className="bg-[var(--color-bg-card)] rounded-xl p-4 border border-[var(--color-border)] hover:border-[var(--color-primary)] transition-colors"
>
<div className="flex items-center gap-4">
{/* Check-in button */}
<button
onClick={() => !habit.CHECKED_TODAY && handleCheckin(habit.ID)}
disabled={habit.CHECKED_TODAY}
className={`w-12 h-12 rounded-xl flex items-center justify-center flex-shrink-0 transition-all ${
habit.CHECKED_TODAY
? "opacity-90"
: "opacity-50 hover:opacity-100 hover:scale-105"
}`}
style={{ backgroundColor: habit.COLOR || "#6366f1" }}
>
{habit.CHECKED_TODAY ? (
<svg className="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
) : (
<span className="text-white text-lg font-bold">+</span>
)}
</button>
{/* Info */}
<div className="flex-1 min-w-0">
<h3 className="font-medium">{habit.NAME}</h3>
<div className="flex items-center gap-4 mt-1">
<span className="text-sm" style={{ color: habit.COLOR || "#6366f1" }}>
{habit.STREAK_CURRENT > 0 ? `${habit.STREAK_CURRENT}일 연속` : "시작 전"}
</span>
{habit.STREAK_BEST > 0 && (
<span className="text-xs text-[var(--color-text-muted)]">
: {habit.STREAK_BEST}
</span>
)}
</div>
</div>
{/* Delete */}
<button
onClick={() => handleDelete(habit.ID)}
className="text-xs text-red-400 hover:text-red-300 px-2"
>
</button>
</div>
</div>
))}
</div>
)}
</main> </main>
</AuthGuard> </AuthGuard>
); );

View File

@@ -0,0 +1,367 @@
"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";
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;
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",
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 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", "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", "CHUNKING", "CATEGORIZING"].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 === "CHUNKING" && "청킹 처리 중..."}
{item.STATUS === "CATEGORIZING" && "카테고리 분류 중..."}
{item.STATUS === "EMBEDDING" && "벡터 임베딩 중..."}
</span>
</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 () => {
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,22 +1,112 @@
"use client"; "use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import AuthGuard from "@/components/auth-guard"; import AuthGuard from "@/components/auth-guard";
import NavBar from "@/components/nav-bar"; import NavBar from "@/components/nav-bar";
import { useApi } from "@/lib/use-api";
interface KnowledgeItem {
ID: string;
TYPE: string;
TITLE: string;
SOURCE_URL: string;
STATUS: string;
CREATED_AT: string;
}
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",
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",
};
export default function KnowledgePage() { export default function KnowledgePage() {
const { request } = useApi();
const [items, setItems] = useState<KnowledgeItem[]>([]);
const [loading, setLoading] = useState(true);
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);
}
};
useEffect(() => {
fetchItems();
// Poll for status updates every 5 seconds
const interval = setInterval(fetchItems, 5000);
return () => clearInterval(interval);
}, []);
return ( return (
<AuthGuard> <AuthGuard>
<NavBar /> <NavBar />
<main className="max-w-7xl mx-auto px-4 py-8"> <main className="max-w-7xl mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Knowledge</h1> <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"> <Link
href="/knowledge/add"
className="px-4 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors"
>
+ Add Knowledge + Add Knowledge
</button> </Link>
</div>
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)]">
<p className="text-[var(--color-text-muted)]">No knowledge items yet. Add your first item to get started.</p>
</div> </div>
{loading ? (
<p className="text-[var(--color-text-muted)]">Loading...</p>
) : 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">
{items.map((item) => (
<Link
key={item.ID}
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.SOURCE_URL && (
<p className="text-sm text-[var(--color-text-muted)] truncate mt-1">{item.SOURCE_URL}</p>
)}
</div>
<span className="text-sm text-[var(--color-text-muted)] ml-4 whitespace-nowrap">
{new Date(item.CREATED_AT).toLocaleDateString()}
</span>
</div>
</Link>
))}
</div>
)}
</main> </main>
</AuthGuard> </AuthGuard>
); );

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"; "use client";
import { useState } from "react"; import { useEffect } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useAuth } from "@/lib/auth-context"; import { useAuth } from "@/lib/auth-context";
const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "";
const REDIRECT_URI = `${typeof window !== "undefined" ? window.location.origin : ""}/login/callback`;
export default function LoginPage() { export default function LoginPage() {
const router = useRouter(); const router = useRouter();
const { login } = useAuth(); const { isAuthenticated, isLoading } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const handleGoogleLogin = async () => { useEffect(() => {
setIsLoading(true); if (!isLoading && isAuthenticated) {
try { router.replace("/dashboard");
// TODO: Implement Google OAuth flow
// For now, placeholder
alert("Google OAuth not configured yet");
} catch (error) {
console.error("Login failed:", error);
} finally {
setIsLoading(false);
} }
}, [isAuthenticated, isLoading, router]);
const handleGoogleLogin = () => {
const params = new URLSearchParams({
client_id: GOOGLE_CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: "code",
scope: "openid email profile",
access_type: "offline",
prompt: "consent",
});
window.location.href = `https://accounts.google.com/o/oauth2/v2/auth?${params.toString()}`;
}; };
return ( return (
@@ -33,8 +40,7 @@ export default function LoginPage() {
</div> </div>
<button <button
onClick={handleGoogleLogin} onClick={handleGoogleLogin}
disabled={isLoading} className="w-full py-3 px-4 bg-white text-gray-800 rounded-lg font-medium hover:bg-gray-100 transition-colors flex items-center justify-center gap-3"
className="w-full py-3 px-4 bg-white text-gray-800 rounded-lg font-medium hover:bg-gray-100 transition-colors disabled:opacity-50 flex items-center justify-center gap-3"
> >
<svg className="w-5 h-5" viewBox="0 0 24 24"> <svg className="w-5 h-5" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"/> <path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"/>
@@ -42,7 +48,7 @@ export default function LoginPage() {
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/> <path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/> <path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg> </svg>
{isLoading ? "Signing in..." : "Sign in with Google"} Sign in with Google
</button> </button>
</div> </div>
</div> </div>

View File

@@ -1,17 +1,177 @@
"use client"; "use client";
import { useEffect, useState } from "react";
import AuthGuard from "@/components/auth-guard"; import AuthGuard from "@/components/auth-guard";
import NavBar from "@/components/nav-bar"; import NavBar from "@/components/nav-bar";
import { useApi } from "@/lib/use-api";
interface StudyCard {
ID: string;
KNOWLEDGE_ITEM_ID: string | null;
KNOWLEDGE_TITLE: string | null;
FRONT: string;
BACK: string;
EASE_FACTOR: number;
INTERVAL_DAYS: number;
REPETITIONS: number;
}
const ratingButtons = [
{ value: 0, label: "모름", color: "bg-red-600 hover:bg-red-500" },
{ value: 1, label: "거의 모름", color: "bg-red-500 hover:bg-red-400" },
{ value: 2, label: "어려움", color: "bg-orange-500 hover:bg-orange-400" },
{ value: 3, label: "보통", color: "bg-yellow-500 hover:bg-yellow-400" },
{ value: 4, label: "쉬움", color: "bg-green-500 hover:bg-green-400" },
{ value: 5, label: "완벽", color: "bg-green-600 hover:bg-green-500" },
];
export default function StudyPage() { export default function StudyPage() {
const { request } = useApi();
const [cards, setCards] = useState<StudyCard[]>([]);
const [loading, setLoading] = useState(true);
const [currentIndex, setCurrentIndex] = useState(0);
const [showBack, setShowBack] = useState(false);
const [reviewing, setReviewing] = useState(false);
const fetchDueCards = async () => {
try {
const data = await request<StudyCard[]>({ method: "GET", url: "/api/study-cards/due" });
setCards(data);
setCurrentIndex(0);
setShowBack(false);
} catch (err) {
console.error("Failed to load cards:", err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchDueCards();
}, []);
const handleReview = async (rating: number) => {
if (reviewing || !cards[currentIndex]) return;
setReviewing(true);
try {
await request({
method: "POST",
url: `/api/study-cards/${cards[currentIndex].ID}/review`,
data: { rating },
});
if (currentIndex + 1 < cards.length) {
setCurrentIndex(currentIndex + 1);
setShowBack(false);
} else {
// 모든 카드 완료
setCards([]);
}
} catch (err) {
console.error("Failed to review card:", err);
} finally {
setReviewing(false);
}
};
const currentCard = cards[currentIndex];
return ( return (
<AuthGuard> <AuthGuard>
<NavBar /> <NavBar />
<main className="max-w-7xl mx-auto px-4 py-8"> <main className="max-w-2xl mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-6">Study Cards</h1> <div className="flex justify-between items-center mb-6">
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)] flex items-center justify-center min-h-[40vh]"> <h1 className="text-2xl font-bold">Study Cards</h1>
<p className="text-[var(--color-text-muted)]">No cards due for review. Generate cards from your knowledge items.</p> {cards.length > 0 && (
<span className="text-sm text-[var(--color-text-muted)]">
{currentIndex + 1} / {cards.length}
</span>
)}
</div> </div>
{loading ? (
<p className="text-[var(--color-text-muted)]">Loading...</p>
) : cards.length === 0 ? (
<div className="bg-[var(--color-bg-card)] rounded-xl p-8 border border-[var(--color-border)] text-center">
<p className="text-lg mb-2">
{currentIndex > 0 ? "복습 완료!" : "복습할 카드가 없습니다."}
</p>
<p className="text-[var(--color-text-muted)] text-sm">
Knowledge .
</p>
</div>
) : currentCard ? (
<div>
{/* Source */}
{currentCard.KNOWLEDGE_TITLE && (
<p className="text-xs text-[var(--color-text-muted)] mb-2">
: {currentCard.KNOWLEDGE_TITLE}
</p>
)}
{/* Card */}
<div
className="bg-[var(--color-bg-card)] rounded-xl border border-[var(--color-border)] min-h-[300px] flex flex-col cursor-pointer"
onClick={() => !showBack && setShowBack(true)}
>
{/* Front */}
<div className="flex-1 flex items-center justify-center p-8">
<div className="text-center">
<p className="text-xs text-[var(--color-text-muted)] mb-3">Question</p>
<p className="text-lg whitespace-pre-wrap">{currentCard.FRONT}</p>
</div>
</div>
{/* Back (revealed) */}
{showBack && (
<div className="border-t border-[var(--color-border)] flex-1 flex items-center justify-center p-8 bg-[var(--color-bg-hover)]">
<div className="text-center">
<p className="text-xs text-[var(--color-text-muted)] mb-3">Answer</p>
<p className="text-lg whitespace-pre-wrap">{currentCard.BACK}</p>
</div>
</div>
)}
</div>
{/* Actions */}
{!showBack ? (
<div className="mt-4 text-center">
<button
onClick={() => setShowBack(true)}
className="px-8 py-3 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-xl transition-colors"
>
</button>
</div>
) : (
<div className="mt-4">
<p className="text-sm text-[var(--color-text-muted)] text-center mb-3">
?
</p>
<div className="grid grid-cols-3 gap-2">
{ratingButtons.map((btn) => (
<button
key={btn.value}
onClick={() => handleReview(btn.value)}
disabled={reviewing}
className={`${btn.color} text-white py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-40`}
>
{btn.label}
</button>
))}
</div>
</div>
)}
{/* Progress bar */}
<div className="mt-4 h-1 bg-[var(--color-bg-hover)] rounded-full overflow-hidden">
<div
className="h-full bg-[var(--color-primary)] transition-all"
style={{ width: `${((currentIndex) / cards.length) * 100}%` }}
/>
</div>
</div>
) : null}
</main> </main>
</AuthGuard> </AuthGuard>
); );

View File

@@ -1,22 +1,367 @@
"use client"; "use client";
import { useEffect, useState } from "react";
import AuthGuard from "@/components/auth-guard"; import AuthGuard from "@/components/auth-guard";
import NavBar from "@/components/nav-bar"; import NavBar from "@/components/nav-bar";
import { useApi } from "@/lib/use-api";
interface Todo {
ID: string;
PARENT_ID: string | null;
TITLE: string;
DESCRIPTION: string | null;
STATUS: string;
PRIORITY: string;
DUE_DATE: string | null;
SUBTASK_COUNT: number;
SUBTASK_DONE_COUNT: number;
}
interface Subtask {
ID: string;
TITLE: string;
STATUS: string;
}
const priorityColors: Record<string, string> = {
HIGH: "text-red-400",
MEDIUM: "text-yellow-400",
LOW: "text-green-400",
};
const priorityLabels: Record<string, string> = {
HIGH: "높음",
MEDIUM: "보통",
LOW: "낮음",
};
export default function TodosPage() { export default function TodosPage() {
const { request } = useApi();
const [todos, setTodos] = useState<Todo[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<"ALL" | "PENDING" | "DONE">("ALL");
// Add form
const [showAdd, setShowAdd] = useState(false);
const [newTitle, setNewTitle] = useState("");
const [newPriority, setNewPriority] = useState("MEDIUM");
const [newDueDate, setNewDueDate] = useState("");
// Subtasks
const [expandedId, setExpandedId] = useState<string | null>(null);
const [subtasks, setSubtasks] = useState<Subtask[]>([]);
const [newSubtask, setNewSubtask] = useState("");
const fetchTodos = async () => {
try {
const status = filter === "ALL" ? undefined : filter;
const params = new URLSearchParams();
if (status) params.set("status", status);
const data = await request<Todo[]>({ method: "GET", url: `/api/todos?${params}` });
setTodos(data);
} catch (err) {
console.error("Failed to load todos:", err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchTodos();
}, [filter]);
const handleAdd = async () => {
if (!newTitle.trim()) return;
try {
await request({
method: "POST",
url: "/api/todos",
data: {
title: newTitle.trim(),
priority: newPriority,
dueDate: newDueDate || null,
},
});
setNewTitle("");
setNewDueDate("");
setShowAdd(false);
fetchTodos();
} catch (err) {
console.error("Failed to create todo:", err);
}
};
const handleToggleStatus = async (todo: Todo) => {
const newStatus = todo.STATUS === "DONE" ? "PENDING" : "DONE";
try {
await request({
method: "PATCH",
url: `/api/todos/${todo.ID}`,
data: { status: newStatus },
});
fetchTodos();
if (expandedId === todo.ID) fetchSubtasks(todo.ID);
} catch (err) {
console.error("Failed to update todo:", err);
}
};
const handleDelete = async (id: string) => {
try {
await request({ method: "DELETE", url: `/api/todos/${id}` });
if (expandedId === id) setExpandedId(null);
fetchTodos();
} catch (err) {
console.error("Failed to delete todo:", err);
}
};
const fetchSubtasks = async (todoId: string) => {
try {
const data = await request<Subtask[]>({ method: "GET", url: `/api/todos/${todoId}/subtasks` });
setSubtasks(data);
} catch (err) {
console.error("Failed to load subtasks:", err);
}
};
const handleExpand = (todoId: string) => {
if (expandedId === todoId) {
setExpandedId(null);
return;
}
setExpandedId(todoId);
fetchSubtasks(todoId);
};
const handleAddSubtask = async () => {
if (!newSubtask.trim() || !expandedId) return;
try {
await request({
method: "POST",
url: "/api/todos",
data: { title: newSubtask.trim(), priority: "MEDIUM", parentId: expandedId },
});
setNewSubtask("");
fetchSubtasks(expandedId);
fetchTodos();
} catch (err) {
console.error("Failed to create subtask:", err);
}
};
const handleToggleSubtask = async (subtask: Subtask) => {
const newStatus = subtask.STATUS === "DONE" ? "PENDING" : "DONE";
try {
await request({
method: "PATCH",
url: `/api/todos/${subtask.ID}`,
data: { status: newStatus },
});
if (expandedId) fetchSubtasks(expandedId);
fetchTodos();
} catch (err) {
console.error("Failed to update subtask:", err);
}
};
const isOverdue = (dueDate: string | null) => {
if (!dueDate) return false;
return new Date(dueDate) < new Date(new Date().toDateString());
};
return ( return (
<AuthGuard> <AuthGuard>
<NavBar /> <NavBar />
<main className="max-w-7xl mx-auto px-4 py-8"> <main className="max-w-3xl mx-auto px-4 py-8">
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">Todos</h1> <h1 className="text-2xl font-bold">Todos</h1>
<button className="px-4 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors"> <button
onClick={() => setShowAdd(!showAdd)}
className="px-4 py-2 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] rounded-lg transition-colors text-sm"
>
+ Add Todo + Add Todo
</button> </button>
</div> </div>
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)]">
<p className="text-[var(--color-text-muted)]">No todos yet. Create your first task.</p> {/* Add form */}
{showAdd && (
<div className="bg-[var(--color-bg-card)] rounded-xl p-4 border border-[var(--color-border)] mb-4 space-y-3">
<input
type="text"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
placeholder="할 일 입력..."
autoFocus
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none"
/>
<div className="flex gap-3 items-center">
<select
value={newPriority}
onChange={(e) => setNewPriority(e.target.value)}
className="px-3 py-1.5 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] text-sm focus:outline-none"
>
<option value="HIGH"></option>
<option value="MEDIUM"></option>
<option value="LOW"></option>
</select>
<input
type="date"
value={newDueDate}
onChange={(e) => setNewDueDate(e.target.value)}
className="px-3 py-1.5 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] text-sm focus:outline-none"
/>
<button
onClick={handleAdd}
disabled={!newTitle.trim()}
className="px-4 py-1.5 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] disabled:opacity-40 rounded-lg text-sm transition-colors"
>
</button>
</div>
</div>
)}
{/* Filters */}
<div className="flex gap-2 mb-4">
{(["ALL", "PENDING", "DONE"] as const).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-3 py-1 rounded-lg text-sm transition-colors ${
filter === f
? "bg-[var(--color-primary)] text-white"
: "bg-[var(--color-bg-card)] text-[var(--color-text-muted)] border border-[var(--color-border)]"
}`}
>
{f === "ALL" ? "전체" : f === "PENDING" ? "진행중" : "완료"}
</button>
))}
</div> </div>
{/* Todo list */}
{loading ? (
<p className="text-[var(--color-text-muted)]">Loading...</p>
) : todos.length === 0 ? (
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)]">
<p className="text-[var(--color-text-muted)]">
{filter === "ALL" ? "아직 할 일이 없습니다." : filter === "PENDING" ? "진행중인 할 일이 없습니다." : "완료된 할 일이 없습니다."}
</p>
</div>
) : (
<div className="space-y-2">
{todos.map((todo) => (
<div key={todo.ID}>
<div className="bg-[var(--color-bg-card)] rounded-xl p-4 border border-[var(--color-border)] hover:border-[var(--color-primary)] transition-colors">
<div className="flex items-center gap-3">
{/* Checkbox */}
<button
onClick={() => handleToggleStatus(todo)}
className={`w-5 h-5 rounded border-2 flex-shrink-0 flex items-center justify-center transition-colors ${
todo.STATUS === "DONE"
? "bg-[var(--color-primary)] border-[var(--color-primary)]"
: "border-[var(--color-border)] hover:border-[var(--color-primary)]"
}`}
>
{todo.STATUS === "DONE" && (
<svg className="w-3 h-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</button>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`${todo.STATUS === "DONE" ? "line-through text-[var(--color-text-muted)]" : ""}`}>
{todo.TITLE}
</span>
<span className={`text-xs ${priorityColors[todo.PRIORITY]}`}>
{priorityLabels[todo.PRIORITY]}
</span>
</div>
<div className="flex items-center gap-3 mt-1">
{todo.DUE_DATE && (
<span className={`text-xs ${isOverdue(todo.DUE_DATE) && todo.STATUS !== "DONE" ? "text-red-400" : "text-[var(--color-text-muted)]"}`}>
{todo.DUE_DATE}
</span>
)}
{todo.SUBTASK_COUNT > 0 && (
<span className="text-xs text-[var(--color-text-muted)]">
{todo.SUBTASK_DONE_COUNT}/{todo.SUBTASK_COUNT} subtasks
</span>
)}
</div>
</div>
{/* Actions */}
<button
onClick={() => handleExpand(todo.ID)}
className="text-xs text-[var(--color-text-muted)] hover:text-[var(--color-primary)] px-2"
>
{expandedId === todo.ID ? "▼" : "▶"}
</button>
<button
onClick={() => handleDelete(todo.ID)}
className="text-xs text-red-400 hover:text-red-300 px-1"
>
</button>
</div>
</div>
{/* Subtasks */}
{expandedId === todo.ID && (
<div className="ml-8 mt-1 space-y-1">
{subtasks.map((st) => (
<div
key={st.ID}
className="flex items-center gap-3 bg-[var(--color-bg-card)] rounded-lg px-3 py-2 border border-[var(--color-border)]"
>
<button
onClick={() => handleToggleSubtask(st)}
className={`w-4 h-4 rounded border-2 flex-shrink-0 flex items-center justify-center transition-colors ${
st.STATUS === "DONE"
? "bg-[var(--color-primary)] border-[var(--color-primary)]"
: "border-[var(--color-border)] hover:border-[var(--color-primary)]"
}`}
>
{st.STATUS === "DONE" && (
<svg className="w-2.5 h-2.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</button>
<span className={`text-sm flex-1 ${st.STATUS === "DONE" ? "line-through text-[var(--color-text-muted)]" : ""}`}>
{st.TITLE}
</span>
</div>
))}
{/* Add subtask */}
<div className="flex gap-2">
<input
type="text"
value={newSubtask}
onChange={(e) => setNewSubtask(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAddSubtask()}
placeholder="서브태스크 추가..."
className="flex-1 px-3 py-1.5 rounded-lg bg-[var(--color-bg-card)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none text-sm"
/>
<button
onClick={handleAddSubtask}
disabled={!newSubtask.trim()}
className="px-3 py-1.5 bg-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] disabled:opacity-40 rounded-lg text-xs transition-colors"
>
</button>
</div>
</div>
)}
</div>
))}
</div>
)}
</main> </main>
</AuthGuard> </AuthGuard>
); );

View File

@@ -1,10 +1,96 @@
import axios from "axios"; import axios, { AxiosError, InternalAxiosRequestConfig } from "axios";
export const api = axios.create({ export const api = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080", baseURL: process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080",
withCredentials: true, withCredentials: true,
}); });
// --- 공통 토큰 refresh 로직 (mutex 패턴) ---
let isRefreshing = false;
let pendingQueue: {
resolve: (token: string) => void;
reject: (error: unknown) => void;
}[] = [];
// auth-context에서 주입하는 콜백
let onTokenRefreshed: ((token: string) => void) | null = null;
let onRefreshFailed: (() => void) | null = null;
export function setAuthCallbacks(
onRefreshed: (token: string) => void,
onFailed: () => void
) {
onTokenRefreshed = onRefreshed;
onRefreshFailed = onFailed;
}
function processQueue(token: string | null, error: unknown) {
pendingQueue.forEach(({ resolve, reject }) => {
if (token) {
resolve(token);
} else {
reject(error);
}
});
pendingQueue = [];
}
api.interceptors.response.use(
(response) => response,
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean };
// 401이 아니거나, refresh 요청 자체가 실패한 경우, 이미 retry한 경우 → 그냥 throw
if (
error.response?.status !== 401 ||
originalRequest.url?.includes("/api/auth/") ||
originalRequest._retry
) {
return Promise.reject(error);
}
// 이미 refresh 진행 중이면 큐에 대기
if (isRefreshing) {
return new Promise((resolve, reject) => {
pendingQueue.push({
resolve: (token: string) => {
originalRequest._retry = true;
originalRequest.headers["Authorization"] = `Bearer ${token}`;
resolve(api.request(originalRequest));
},
reject,
});
});
}
// refresh 시작
isRefreshing = true;
originalRequest._retry = true;
try {
const res = await api.post<LoginResponse>("/api/auth/refresh");
const newToken = res.data.accessToken;
api.defaults.headers.common["Authorization"] = `Bearer ${newToken}`;
onTokenRefreshed?.(newToken);
// 대기 중인 요청들 처리
processQueue(newToken, null);
// 원래 요청 retry
originalRequest.headers["Authorization"] = `Bearer ${newToken}`;
return api.request(originalRequest);
} catch (refreshError) {
processQueue(null, refreshError);
onRefreshFailed?.();
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
);
// Types // Types
export interface LoginResponse { export interface LoginResponse {
accessToken: string; accessToken: string;

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import React, { createContext, useContext, useState, useCallback, useEffect } from "react"; import React, { createContext, useContext, useState, useCallback, useEffect, useRef } from "react";
import { api, LoginResponse } from "./api"; import { api, LoginResponse, setAuthCallbacks } from "./api";
interface AuthContextType { interface AuthContextType {
isAuthenticated: boolean; isAuthenticated: boolean;
@@ -24,13 +24,24 @@ const AuthContext = createContext<AuthContextType>({
export function AuthProvider({ children }: { children: React.ReactNode }) { export function AuthProvider({ children }: { children: React.ReactNode }) {
const [accessToken, setAccessToken] = useState<string | null>(null); const [accessToken, setAccessToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const logoutRef = useRef<() => void>(() => {});
// interceptor 콜백 등록
useEffect(() => {
setAuthCallbacks(
(token: string) => setAccessToken(token),
() => logoutRef.current()
);
}, []);
useEffect(() => { useEffect(() => {
// Try to restore session from refresh token cookie // Try to restore session from refresh token cookie
const tryRefresh = async () => { const tryRefresh = async () => {
try { try {
const res = await api.post<LoginResponse>("/api/auth/refresh"); const res = await api.post<LoginResponse>("/api/auth/refresh");
setAccessToken(res.data.accessToken); const token = res.data.accessToken;
setAccessToken(token);
api.defaults.headers.common["Authorization"] = `Bearer ${token}`;
} catch { } catch {
// No valid session // No valid session
} finally { } finally {
@@ -62,6 +73,11 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
window.location.href = "/login"; window.location.href = "/login";
}, []); }, []);
// ref로 최신 logout 유지 (interceptor에서 사용)
useEffect(() => {
logoutRef.current = logout;
}, [logout]);
return ( return (
<AuthContext.Provider <AuthContext.Provider
value={{ value={{

View File

@@ -1,35 +1,16 @@
"use client"; "use client";
import { useCallback } from "react"; import { useCallback } from "react";
import { api, LoginResponse } from "./api"; import { api } from "./api";
import { useAuth } from "./auth-context";
import { AxiosRequestConfig } from "axios"; import { AxiosRequestConfig } from "axios";
export function useApi() { export function useApi() {
const { setAccessToken, logout } = useAuth();
const request = useCallback( const request = useCallback(
async <T>(config: AxiosRequestConfig): Promise<T> => { async <T>(config: AxiosRequestConfig): Promise<T> => {
try { const response = await api.request<T>(config);
const response = await api.request<T>(config); return response.data;
return response.data;
} catch (error: any) {
if (error.response?.status === 401) {
try {
const refreshRes = await api.post<LoginResponse>("/api/auth/refresh");
setAccessToken(refreshRes.data.accessToken);
api.defaults.headers.common["Authorization"] = `Bearer ${refreshRes.data.accessToken}`;
const retryResponse = await api.request<T>(config);
return retryResponse.data;
} catch {
logout();
throw error;
}
}
throw error;
}
}, },
[setAccessToken, logout] []
); );
return { request }; return { request };