Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions frontend/src/components/dialogs/SaveLinkDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,23 +182,24 @@ export function SaveLinkDialog() {
// 태그: 이전 AI 추천 태그를 제거하고 새 추천 적용 (재분석 시 이전 상태 초기화)
const prevAiTags = aiSuggestedTags;
const baseTags = selectedTags.filter(tag => !prevAiTags.has(tag)); // 수동 선택 태그만 남김
const newTagNames = new Set<string>();
const aiTagNames = new Set<string>();

if (result.suggestedTags?.length) {
const tagsToSelect = [...baseTags];
for (const tag of result.suggestedTags) {
if (!tagsToSelect.includes(tag.tagName)) {
tagsToSelect.push(tag.tagName);
}
if (!tag.isExisting) {
newTagNames.add(tag.tagName);
// 이미 수동으로 선택된 태그가 아닌 경우에만 AI 추천 태그로 기록
if (!baseTags.includes(tag.tagName)) {
aiTagNames.add(tag.tagName);
}
}
setSelectedTags(tagsToSelect);
} else {
setSelectedTags(baseTags);
}
setAiSuggestedTags(newTagNames);
setAiSuggestedTags(aiTagNames);

// 폴더: 이전 AI 추천 상태 초기화 후 새 추천 적용
setPinnedFolderId(null);
Expand Down
22 changes: 18 additions & 4 deletions frontend/src/components/providers/QueryProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';
import { ApiError, getApiErrorMessage } from '@/lib/api/fetchClient';

export function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
Expand All @@ -13,14 +14,27 @@ export function QueryProvider({ children }: { children: React.ReactNode }) {
retry: 1, // 요청 실패 시 1회 재시도
},
mutations: {
onError: (error: any) => {
onError: (error: unknown) => {
// 1. 보안을 위해 오직 '개발 환경(development)'에서만 상세한 에러 정보 출력
if (process.env.NODE_ENV === 'development') {
console.error('[API Mutation Error]:', error);
if (error instanceof ApiError) {
console.warn('[API Mutation Warning]:', {
status: error.status,
code: error.code,
message: error.message,
});
} else {
console.error('[API Mutation Error]:', error);
}
}

// 2. 사용자에게는 어떤 상황에서도 기술 정보 없이 일반적인 안내 문구만 제공
alert('요청 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.');
// 2. 로그인 만료 에러(AUTH_EXPIRED)는 자동 리다이렉트되므로 alert를 띄우지 않음
if (error instanceof ApiError && error.code === 'AUTH_EXPIRED') {
return;
}

// 3. 그 외 백엔드가 내려준 사용자용 메시지가 있으면 안내한다.
alert(getApiErrorMessage(error));
Comment on lines 30 to +37

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

AUTH_EXPIRED 에러 시 alert()/login 리다이렉트를 차단

fetchClient는 토큰 갱신 실패 시 window.location.href = '/login'을 설정한 직후 ApiError({ code: 'AUTH_EXPIRED' })를 throw합니다(fetchClient.ts Lines 108–115). React Query가 이를 onError로 전달하면 alert()가 실행되어 브라우저의 다이얼로그가 네비게이션을 블로킹합니다. 사용자는 알림을 직접 닫아야 로그인 페이지로 이동할 수 있습니다.

이 동작은 해당 throw 구문이 이번 PR에서 신규 추가(~)된 것이므로 새로 도입된 회귀입니다.

🛡️ 제안 수정 — AUTH_EXPIRED alert 생략
-              // 2. 백엔드가 내려준 사용자용 메시지가 있으면 그대로 안내한다.
-              alert(getApiErrorMessage(error));
+              // 2. AUTH_EXPIRED는 /login 리다이렉트 중이므로 alert 를 생략한다.
+              if (error instanceof ApiError && error.code === 'AUTH_EXPIRED') return;
+              alert(getApiErrorMessage(error));
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// 2. 사용자에게는 어떤 상황에서도 기술 정보 없이 일반적인 안내 문구만 제공
alert('요청 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.');
// 2. 백엔드가 내려준 사용자용 메시지가 있으면 그대로 안내한다.
alert(getApiErrorMessage(error));
// 2. AUTH_EXPIRED는 /login 리다이렉트 중이므로 alert 를 생략한다.
if (error instanceof ApiError && error.code === 'AUTH_EXPIRED') return;
alert(getApiErrorMessage(error));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/providers/QueryProvider.tsx` around lines 30 - 32,
The onError handler in QueryProvider.tsx calls alert(getApiErrorMessage(error))
unconditionally, which blocks navigation when fetchClient throws ApiError({
code: 'AUTH_EXPIRED' }); update the handler to detect that error (check
error.code === 'AUTH_EXPIRED' or error instanceof ApiError with code) and skip
showing the alert in that case so the existing fetchClient redirect to '/login'
is not blocked; for all other errors continue to call getApiErrorMessage(error)
and alert as before.

},
},
},
Expand Down
56 changes: 50 additions & 6 deletions frontend/src/lib/api/fetchClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,34 @@ import type { ApiResponse } from '@/lib/types/apiResponse';
import { buildBackendUrl } from '@/lib/config/backend';
import { useAuthStore } from '@/lib/store/authStore';

const DEFAULT_API_ERROR_MESSAGE = '요청 처리 중 오류가 발생했습니다. 잠시 후 다시 시도해 주세요.';

type ApiErrorParams = {
message: string;
status?: number;
code?: string;
};

export class ApiError extends Error {
readonly status?: number;
readonly code?: string;

constructor({ message, status, code }: ApiErrorParams) {
super(message);
this.name = 'ApiError';
this.status = status;
this.code = code;
}
}

export function getApiErrorMessage(error: unknown): string {
if (error instanceof ApiError && error.message) {
return error.message;
}

return DEFAULT_API_ERROR_MESSAGE;
}

// 여러 API 가 동시에 401 을 받아도 refresh 는 1회만 보내도록 Promise 를 공유한다.
let refreshPromise: Promise<boolean> | null = null;

Expand Down Expand Up @@ -80,7 +108,11 @@ export async function fetchClient<T>(
if (typeof window !== 'undefined') {
window.location.href = '/login';
}
throw new Error('인증이 만료되었습니다.');
throw new ApiError({
status: 401,
code: 'AUTH_EXPIRED',
message: '인증이 만료되었습니다.',
});
}

return parseResponse<T>(response);
Expand All @@ -91,14 +123,22 @@ async function parseResponse<T>(response: Response): Promise<T> {
// 2xx 외 HTTP 오류 처리
if (!response.ok) {
const text = await response.text().catch(() => '');
let message = `HTTP ${response.status}`;
let message = DEFAULT_API_ERROR_MESSAGE;
let code: string | undefined;
try {
const parsed = JSON.parse(text) as ApiResponse<unknown>;
if (parsed.error?.message) message = parsed.error.message;
if (parsed.error?.message) {
message = parsed.error.message;
code = parsed.error.code;
}
} catch {
if (text) message = text;
// ApiResponse 형식이 아닌 응답 본문은 사용자에게 그대로 노출하지 않는다.
}
throw new Error(message);
throw new ApiError({
status: response.status,
code,
message,
});
}

// 204 No Content 또는 빈 응답
Expand All @@ -111,7 +151,11 @@ async function parseResponse<T>(response: Response): Promise<T> {

// HTTP는 200이지만 비즈니스 로직 에러인 경우 (success: false)
if (!json.success) {
throw new Error(json.error?.message ?? '알 수 없는 에러');
throw new ApiError({
status: response.status,
code: json.error?.code,
message: json.error?.message ?? DEFAULT_API_ERROR_MESSAGE,
});
}

return json.data;
Expand Down
6 changes: 6 additions & 0 deletions src/main/java/com/web/SearchWeb/bookmark/dao/BookmarkDao.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import org.apache.ibatis.annotations.Param;

import java.util.List;
import java.util.Map;

public interface BookmarkDao {
//북마크 추가
Expand Down Expand Up @@ -62,4 +63,9 @@ public interface BookmarkDao {

// 폴더 내 활성 북마크 존재 여부
boolean existsActiveBookmarkInFolder(Long memberFolderId);

// 폴더별 컨텍스트 조회 (LLM 폴더 추천용 - LATERAL aggregation)
List<Map<String, Object>> selectFolderContexts(@Param("memberId") Long memberId,
@Param("sampleLimit") int sampleLimit,
@Param("tagLimit") int tagLimit);
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Map;

@Repository
public class MybatisBookmarkDao implements BookmarkDao {
Expand Down Expand Up @@ -169,4 +170,12 @@ public int insertBookmarkTags(Long bookmarkId, List<Long> tagIds) {
public boolean existsActiveBookmarkInFolder(Long memberFolderId) {
return mapper.existsActiveBookmarkInFolder(memberFolderId);
}

/**
* 폴더별 컨텍스트 조회 (LLM 폴더 추천용 - LATERAL aggregation)
*/
@Override
public List<Map<String, Object>> selectFolderContexts(Long memberId, int sampleLimit, int tagLimit) {
return mapper.selectFolderContexts(memberId, sampleLimit, tagLimit);
}
}
131 changes: 131 additions & 0 deletions src/main/java/com/web/SearchWeb/linkanalysis/domain/FolderContext.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package com.web.SearchWeb.linkanalysis.domain;

import lombok.Builder;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;

import java.sql.Array;
import java.sql.SQLException;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;

/**
* 폴더 컨텍스트 도메인 객체 (LLM 폴더 추천용)
* - BookmarkDao.selectFolderContexts() 결과 행을 변환한 불변 DTO
* - 폴더당 북마크 수, 최근 저장일, 샘플 제목 목록, 자주 쓰인 태그(빈도) 목록을 포함
*/
@Slf4j
@Getter
@Builder
public class FolderContext {

/** 폴더 ID */
private final Long memberFolderId;

/** 폴더명 */
private final String folderName;

/** 폴더 설명 (용도) */
private final String description;

/** 폴더 유형 ("CUSTOM" / "UNORGANIZED") */
private final String folderType;

/** 활성 북마크 개수 */
private final long bookmarkCount;

/** 최근 북마크 저장 일자 (UNORGANIZED 또는 빈 폴더는 null) */
private final LocalDate lastCreatedAt;

/** 샘플 북마크 제목 목록 (최신순 최대 sampleLimit개) */
@Builder.Default
private final List<String> sampleTitles = List.of();

/** 폴더 안의 저장 링크에서 자주 쓰인 태그(빈도) 목록. 예: AI(12) */
@Builder.Default
private final List<String> topTags = List.of();


/**
* MyBatis가 반환한 Map row 한 건을 FolderContext로 변환한다.
* - PostgreSQL ARRAY → java.sql.Array → List<String> 변환 처리
* - timestamp → LocalDate 변환 처리
*/
public static FolderContext fromRow(Map<String, Object> row) {
return FolderContext.builder()
.memberFolderId(toLong(row.get("memberFolderId")))
.folderName(asString(row.get("folderName")))
.description(asString(row.get("description")))
.folderType(asString(row.get("folderType")))
.bookmarkCount(toLong(row.get("bookmarkCount")))
.lastCreatedAt(toLocalDate(row.get("lastCreatedAt")))
.sampleTitles(toStringList(row.get("sampleTitles")))
.topTags(toStringList(row.get("topTags")))
.build();
}


private static String asString(Object value) {
return value != null ? value.toString() : null;
}

private static long toLong(Object value) {
if (value == null) return 0L;
if (value instanceof Number n) return n.longValue();
try {
return Long.parseLong(value.toString());
} catch (NumberFormatException e) {
return 0L;
}
}

private static LocalDate toLocalDate(Object value) {
if (value == null) return null;
if (value instanceof LocalDate ld) return ld;
if (value instanceof LocalDateTime ldt) return ldt.toLocalDate();
if (value instanceof java.sql.Date sqlDate) return sqlDate.toLocalDate();
if (value instanceof java.sql.Timestamp ts) return ts.toLocalDateTime().toLocalDate();
if (value instanceof java.util.Date d) {
return new java.sql.Timestamp(d.getTime()).toLocalDateTime().toLocalDate();
}
return null;
}

@SuppressWarnings("unchecked")
private static List<String> toStringList(Object value) {
if (value == null) return List.of();
if (value instanceof List<?> list) {
List<String> out = new ArrayList<>(list.size());
for (Object o : list) {
if (o != null) out.add(o.toString());
}
return Collections.unmodifiableList(out);
}
if (value instanceof Object[] arr) {
List<String> out = new ArrayList<>(arr.length);
for (Object o : arr) {
if (o != null) out.add(o.toString());
}
return Collections.unmodifiableList(out);
}
if (value instanceof Array sqlArr) {
try {
Object inner = sqlArr.getArray();
if (inner instanceof Object[] objArr) {
List<String> out = new ArrayList<>(objArr.length);
for (Object o : objArr) {
if (o != null) out.add(o.toString());
}
return Collections.unmodifiableList(out);
}
} catch (SQLException e) {
log.debug("[folder-context] failed to read sql array: {}", e.getMessage());
}
}
Comment on lines +115 to +128

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | 💤 Low value

SQLException 무시 처리는 적절하나 로깅 추가 권장

java.sql.Array 변환 시 SQLException을 무시하고 있습니다. Graceful degradation 관점에서는 적절하지만, 디버깅을 위해 DEBUG 레벨 로깅을 추가하면 좋겠습니다.

🔧 선택적 로깅 추가
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
 `@Getter`
 `@Builder`
 public class FolderContext {
     // ...
             } catch (SQLException ignored) {
-                // fall through to empty
+                log.debug("Failed to convert SQL Array to List<String>", ignored);
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/java/com/web/SearchWeb/linkanalysis/domain/FolderContext.java`
around lines 113 - 126, In FolderContext where the Array-to-List conversion
happens (the block checking "if (value instanceof Array sqlArr)"), keep the
current behavior of swallowing SQLException but add a DEBUG-level log in the
catch to record the exception and context (include sqlArr and/or the column name
if available) so failures are visible during debugging; use the existing logger
(or add one if missing) and call logger.debug with a descriptive message and the
caught SQLException, then continue to return the empty/fallback result as
before.

return List.of();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,6 @@ public class PageContent {
/** 전체 URL */
private final String url;

/** 콘텐츠 유형 (JSON-LD @type 또는 og:type, 예: "Article", "Product") */
private final String contentType;

/** 페이지 메타 키워드 (최대 10개) */
@Builder.Default
private final List<String> keywords = List.of();
Expand Down
Loading
Loading