Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
341196e
chore: Spring AI 멀티 프로바이더 인프라 설정
jin2304 Mar 14, 2026
2043fab
feat(link-analysis): AI 링크 분석 백엔드 모듈 구현
jin2304 Mar 14, 2026
e5d77b8
feat(link-analysis): AI 링크 분석 프론트엔드 연동
jin2304 Mar 14, 2026
89cffef
style(ui): 폴더·링크 카드 색상 톤 조정
jin2304 Mar 14, 2026
a1e3ffb
Merge branch 'dev' of github.com:jin2304/SearchWeb_Spring into feat/S…
jin2304 Mar 14, 2026
702f7c1
refactor: config 패키지 구조 변경에 따른 import 경로 및 API 시그니처 수정
jin2304 Mar 14, 2026
e799a49
chore(security): 테스트 환경용 인증 우회 임시 코드 추가
jin2304 Mar 14, 2026
011004e
refactor(bookmark): 도메인 전용 예외 클래스(BookmarkException) 적용
jin2304 Mar 14, 2026
3a55a99
refactor(link-analysis): AI 분석 버튼 클릭 시 title·note 항상 최신값으로 갱신
jin2304 Mar 14, 2026
b747422
docs(backend): 도메인 전용 Exception 및 config 패키지 규칙 추가
jin2304 Mar 14, 2026
bc66852
security(link-analysis): SSRF 방어 및 프롬프트 인젝션 차단
jin2304 Mar 14, 2026
da61fab
refactor(link-analysis): DTO를 Requests/Responses로 분리
jin2304 Mar 14, 2026
89c54c4
feat(link-analysis): PageContent에 url 필드 추가 및 메타데이터 추출 개선
jin2304 Mar 14, 2026
6cc9f5d
fix(config): Groq 기본 활성화 및 AiConfig 개선
jin2304 Mar 14, 2026
610d5b4
fix(frontend): 재분석 시 AI 추천 초기화 및 접근성 개선
jin2304 Mar 14, 2026
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
4 changes: 4 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ configurations {

repositories {
mavenCentral()
maven { url 'https://repo.spring.io/milestone' }
}

dependencies {
Expand All @@ -35,6 +36,9 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'me.paulschwarz:spring-dotenv:4.0.0'
implementation 'org.jsoup:jsoup:1.17.2'
implementation platform('org.springframework.ai:spring-ai-bom:1.1.2')
implementation 'org.springframework.ai:spring-ai-starter-model-openai'
implementation 'org.springframework.ai:spring-ai-starter-model-google-genai'

compileOnly 'org.projectlombok:lombok'
runtimeOnly 'org.postgresql:postgresql'
Expand Down
7 changes: 6 additions & 1 deletion docs/Backend/01. backend-convention.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,16 @@

## 5) 도메인/에러 규칙
- 도메인 객체 생성은 `@Builder`를 사용하고, Builder로 생성한다.
- 에러 코드는 도메인별로 커스텀 `*ErrorCode` 작성한다.
- 에러 코드는 도메인별로 커스텀 `*ErrorCode` enum을 작성한다.
- 에러 코드는 짧고 명확하게 유지한다.
- 각 도메인은 전용 Exception 클래스(`*Exception`)를 작성하여 사용한다.
- 모든 커스텀 예외는 `com.web.SearchWeb.config.exception.BusinessException`을 상속받는다.
- 에러 코드 인터페이스: `com.web.SearchWeb.config.exception.ErrorCode`
- 예시: `linkanalysis` 도메인 → `LinkAnalysisException`

## 6) Config 배치 규칙
- Config는 `config` 폴더 하위에 도메인 폴더를 만든 뒤 배치한다.
- 공통 프레임워크 관련 Config(예외, 응답 등)는 `config.exception`, `config.common` 등에 배치한다.
Comment on lines 34 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

마크다운 포맷팅 수정 필요

헤딩 전후에 빈 줄이 필요합니다.

📝 수정 제안
   - 예시: `linkanalysis` 도메인 → `LinkAnalysisException`
 
+
 ## 6) Config 배치 규칙
 - Config는 `config` 폴더 하위에 도메인 폴더를 만든 뒤 배치한다.
🧰 Tools
🪛 markdownlint-cli2 (0.21.0)

[warning] 35-35: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below

(MD022, blanks-around-headings)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/Backend/01`. backend-convention.md around lines 34 - 37, Add a blank
line immediately before the "## 6) Config 배치 규칙" heading and ensure there is an
empty line after the heading (i.e., between the heading and the following list)
so the Markdown renders correctly; update the section around the "## 6) Config
배치 규칙" heading to include these blank lines.

- 예시: 아이템 도메인 Config -> `config/item/ItemConfig.java`

## 7) 머지 기준
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/app/my-links/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ export default function MyLinksPage() {
className="bg-white dark:bg-card-dark rounded-lg p-2.5 border border-gray-100 dark:border-gray-800 hover:shadow-sm hover:border-purple-200 dark:hover:border-purple-900/50 transition-all duration-300 group cursor-pointer h-[90px] flex flex-col justify-between focus:ring-1 focus:ring-purple-300 outline-none hover:bg-purple-50/50 dark:hover:bg-purple-900/10"
>
<div className="flex justify-between items-start">
<div className="p-1.5 bg-purple-50 dark:bg-purple-900/20 text-purple-500 rounded-md flex items-center justify-center w-8 h-8">
<div className="p-1.5 bg-purple-50/80 dark:bg-purple-900/15 text-gray-400 rounded-md flex items-center justify-center w-8 h-8">
<span className="material-symbols-outlined text-[16px]">folder_open</span>
</div>
<button type="button" className="text-gray-300 hover:text-purple-500">
Expand Down
385 changes: 320 additions & 65 deletions frontend/src/components/dialogs/SaveLinkDialog.tsx

Large diffs are not rendered by default.

24 changes: 12 additions & 12 deletions frontend/src/components/my-links/FolderCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,28 +11,28 @@ interface FolderCardProps {

const COLOR_MAPS = {
blue: {
bg: 'bg-blue-500',
groupHover: 'group-hover:bg-blue-500'
bg: 'bg-blue-400',
groupHover: 'group-hover:bg-blue-400'
},
purple: {
bg: 'bg-purple-500',
groupHover: 'group-hover:bg-purple-500'
bg: 'bg-purple-400',
groupHover: 'group-hover:bg-purple-400'
},
green: {
bg: 'bg-green-500',
groupHover: 'group-hover:bg-green-500'
bg: 'bg-green-400',
groupHover: 'group-hover:bg-green-400'
},
amber: {
bg: 'bg-amber-500',
groupHover: 'group-hover:bg-amber-500'
bg: 'bg-amber-400',
groupHover: 'group-hover:bg-amber-400'
},
rose: {
bg: 'bg-rose-500',
groupHover: 'group-hover:bg-rose-500'
bg: 'bg-rose-400',
groupHover: 'group-hover:bg-rose-400'
},
indigo: {
bg: 'bg-indigo-500',
groupHover: 'group-hover:bg-indigo-500'
bg: 'bg-indigo-400',
groupHover: 'group-hover:bg-indigo-400'
}
};

Expand Down
86 changes: 36 additions & 50 deletions frontend/src/components/my-links/RightPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ function LinkItem({
return (
<div
ref={itemRef}
className={`group relative flex items-center p-3 rounded-xl transition-all cursor-pointer border ${(isBulkEditMode && isSelected) || (isTitleEditing || isNoteEditing || isTagEditing) ? 'bg-purple-50/50 dark:bg-purple-900/10 border-purple-200 dark:border-purple-800' : 'border-transparent hover:border-purple-200/50 dark:hover:border-purple-800/50 hover:bg-purple-50/60 dark:hover:bg-purple-900/10'}`}
className={`group relative z-0 hover:z-20 flex items-center p-3 rounded-xl transition-all duration-300 cursor-pointer border ${(isBulkEditMode && isSelected) || (isTitleEditing || isNoteEditing || isTagEditing) ? 'bg-purple-50/40 dark:bg-purple-900/10 border-purple-200/60 dark:border-purple-800/60' : 'border-transparent hover:border-purple-200/30 dark:hover:border-purple-800/30 hover:bg-purple-50/40 dark:hover:bg-purple-900/5'}`}
onClick={() => {
if (isBulkEditMode && onToggleSelect) {
onToggleSelect(data.bookmarkId);
Expand Down Expand Up @@ -241,9 +241,9 @@ function LinkItem({
)}
</div>
<div className="ml-3 flex-1 flex flex-col min-w-0">
<div className="flex items-center gap-12">
<div className="flex items-center w-full">
{/* Title Area | 제목 영역 (호버 시 전체 제목 및 URL 표시) */}
<div className="w-[240px] shrink-0 flex items-center h-[28px]">
<div className="flex-1 flex items-center min-w-0 h-[28px]">
{isTitleEditing ? (
<input
ref={titleInputRef}
Expand All @@ -256,50 +256,11 @@ function LinkItem({
onClick={(e) => e.stopPropagation()}
/>
) : (
<>
<h4
className="text-[10.5px] font-semibold text-gray-900 dark:text-gray-100 truncate pr-2 group-hover:whitespace-normal group-hover:line-clamp-2 transition-colors cursor-pointer w-full border border-transparent flex items-center px-0 leading-tight"
onDoubleClick={(e) => {
e.stopPropagation();
setIsTitleEditing(true);
}}
title="더블클릭으로 제목 수정"
>{titleContent}</h4>
</>
<h4
className="text-[10.5px] font-semibold text-gray-900 dark:text-gray-100 truncate pr-2 group-hover:whitespace-normal group-hover:line-clamp-2 transition-colors cursor-pointer w-full border border-transparent flex items-center px-0 leading-tight"
>{titleContent}</h4>
Comment on lines +259 to +261
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 | 🟠 Major

제목 더블클릭 편집이 카드 클릭 네비게이션과 충돌합니다.

여기는 더블클릭으로 편집에 들어가려 하지만, 제목이 여전히 링크를 여는 부모 클릭 영역 안에 있어서 첫 클릭에서 새 탭이 먼저 열립니다. 지금 상태로는 제목 편집 시 의도치 않은 네비게이션이 함께 발생하므로 제목 영역 클릭 전파를 막거나 편집 진입 UI를 분리해야 합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/components/my-links/RightPanel.tsx` around lines 259 - 266, The
title's double-click editing conflicts with parent card navigation because
clicks propagate to the parent's link handler; update the h4 to stop event
propagation and prevent default on both click and double-click so the first
click doesn't trigger navigation: add an onClick={(e) => e.stopPropagation()}
(and optionally e.preventDefault()) and modify the existing onDoubleClick
handler (used with setIsTitleEditing) to also call e.stopPropagation() and
e.preventDefault() so the title editing entry is isolated from the parent card's
click handler (referencing the h4 element, onDoubleClick handler, and
setIsTitleEditing).

)}
</div>

{/* Note Area | 메모/설명 영역 */}
<div className="flex-1 flex items-center h-[28px] relative group/note overflow-hidden">
<div className={`w-full flex items-center gap-1.5 text-gray-400 dark:text-gray-500 pointer-events-auto h-full px-0.5
${isNoteEditing ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}
onClick={(e) => {
e.stopPropagation();
if (!isNoteEditing) setIsNoteEditing(true);
}}
>
<div className="flex items-center justify-center h-full shrink-0 mr-1.5">
<span
className="material-symbols-outlined !text-[15px] hover:text-purple-500 transition-colors flex items-center justify-center"
style={{ fontVariationSettings: "'wght' 300" }}
>edit_note</span>
</div>
{isNoteEditing ? (
<input
ref={noteInputRef}
type="text"
className="flex-1 text-[10.5px] font-medium bg-gray-50/80 dark:bg-gray-800/80 border border-gray-200 dark:border-gray-700/50 rounded-md outline-none text-gray-900 dark:text-gray-100 px-2 h-full py-0 leading-none focus:border-gray-300 dark:focus:border-gray-600"
value={noteContent}
onChange={(e) => setNoteContent(e.target.value)}
onBlur={(e) => handleNoteEditComplete(e)}
onKeyDown={handleNoteKeyDown}
onClick={(e) => e.stopPropagation()}
/>
) : (
<p className="flex-1 text-[10.5px] font-medium truncate hover:text-gray-600 dark:hover:text-gray-300 border border-transparent h-full flex items-center px-0 py-0 leading-none">{noteContent || 'Add description...'}</p>
)}
</div>
</div>
</div>

{/* Folder Info & Quick Move | 폴더 정보 및 빠른 이동 */}
Expand All @@ -316,9 +277,9 @@ function LinkItem({
}}
title="폴더 이동"
>
<span className="material-symbols-outlined !text-[11px] scale-90">folder</span>
<span className="material-symbols-outlined !text-[11px] scale-90 text-slate-400">folder</span>
<span>{folders?.find(f => f.memberFolderId === data.memberFolderId)?.folderName ?? 'Unordered'}</span>
<span className={`material-symbols-outlined !text-[10px] transition-transform ${isFolderDropdownOpen ? 'rotate-180' : ''}`}>expand_more</span>
<span className={`material-symbols-outlined !text-[10px] text-slate-400 transition-transform ${isFolderDropdownOpen ? 'rotate-180' : ''}`}>expand_more</span>
</button>

{isFolderDropdownOpen && (
Expand Down Expand Up @@ -387,6 +348,31 @@ function LinkItem({
</div>
</div>

{/* Note Edit Input | 메모 편집창 (편집 모드 시에만 나타남) */}
{isNoteEditing && (
<div className="flex-1 min-w-0 ml-4 h-[28px] z-10">
<input
ref={noteInputRef}
type="text"
className="w-full text-[10.5px] font-medium bg-gray-50/80 dark:bg-gray-800/80 border border-gray-200 dark:border-gray-700/50 rounded-md outline-none text-gray-900 dark:text-gray-100 px-2 h-full py-0 leading-none focus:border-gray-300 dark:focus:border-gray-600"
value={noteContent}
onChange={(e) => setNoteContent(e.target.value)}
onBlur={(e) => handleNoteEditComplete(e)}
onKeyDown={handleNoteKeyDown}
onClick={(e) => e.stopPropagation()}
autoFocus
/>
</div>
)}

{/* Tooltip for full note content (위치 우측 복구 & 꼬리만 왼쪽 유지) */}
{!isNoteEditing && noteContent && !isDropdownOpen && (
<div className="absolute right-12 top-1/2 -translate-y-1/2 mr-2 w-max max-w-[280px] z-[60] opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-300 bg-white/95 dark:bg-gray-800/90 backdrop-blur-md text-gray-700 dark:text-gray-200 text-[11px] font-medium p-3 rounded-xl shadow-xl border border-gray-100 dark:border-gray-700/50 whitespace-normal break-words leading-relaxed pointer-events-none translate-x-1 group-hover:translate-x-0 text-left">
<div className="absolute top-1/2 -translate-y-1/2 -left-1.5 w-3 h-3 bg-white/95 dark:bg-gray-800/90 transform rotate-45 border-b border-l border-gray-100 dark:border-gray-700/50"></div>
<div className="relative z-10 break-all xl:break-words">{noteContent}</div>
</div>
)}

<div className="shrink-0 ml-4 flex items-center justify-end w-8 relative" ref={dropdownRef}>
{!isBulkEditMode && !(isNoteEditing || isTitleEditing) && (
<span className={`text-[8px] text-gray-400 whitespace-nowrap transition-opacity duration-200 absolute right-0 pointer-events-none ${isDropdownOpen ? 'opacity-0' : 'group-hover:opacity-0'}`}>
Expand Down Expand Up @@ -630,7 +616,7 @@ export function RightPanel() {
{/* Left: Title and Count | 좌측: 폴더명 및 링크 개수 */}
<div className="flex flex-col">
<div className="flex items-center gap-2">
<div className="p-1.5 bg-purple-50 dark:bg-purple-900/20 text-purple-500 rounded-md flex items-center justify-center w-8 h-8">
<div className="p-1.5 bg-slate-50 dark:bg-slate-900/20 text-slate-400 rounded-md flex items-center justify-center w-8 h-8">
<span className="material-symbols-outlined text-[16px] block">folder_open</span>
</div>
<h2 className="text-sm font-bold text-gray-900 dark:text-white">{currentFolderName}</h2>
Expand Down Expand Up @@ -920,7 +906,7 @@ export function RightPanel() {
{/* All Folders Section | 모든 폴더 목록 섹션 */}
<div className="pt-1 pb-2">
<div className="px-3 py-2 text-[11px] font-bold text-slate-500 flex items-center gap-1.5">
<span className="material-symbols-outlined !text-[14px] text-[#7c3aed]">folder</span>
<span className="material-symbols-outlined !text-[14px] text-slate-400">folder</span>
All Folders
</div>

Expand All @@ -940,7 +926,7 @@ export function RightPanel() {
}}
className="w-full flex items-center gap-3 p-2 rounded-xl hover:bg-slate-100 dark:hover:bg-gray-800 transition-all text-left group bg-white/50"
>
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-slate-400 shrink-0 bg-slate-100 group-hover:bg-violet-50 group-hover:text-violet-500 transition-colors">
<div className="w-8 h-8 rounded-lg flex items-center justify-center text-slate-400 shrink-0 bg-slate-100 group-hover:bg-slate-200 group-hover:text-slate-500 transition-colors">
<span className="material-symbols-outlined !text-[18px]">folder_open</span>
</div>
<div className="flex-1 min-w-0">
Expand Down
22 changes: 22 additions & 0 deletions frontend/src/lib/api/linkAnalysisApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { useMutation } from '@tanstack/react-query';
import { fetchClient } from './fetchClient';
import type { LinkAnalysisResponse } from '@/lib/types/linkAnalysis';

/**
* AI 링크 분석 (POST /api/link-analysis/analyze)
*/
async function analyzeLink(url: string): Promise<LinkAnalysisResponse> {
return fetchClient<LinkAnalysisResponse>('/api/link-analysis/analyze', {
method: 'POST',
body: JSON.stringify({ url }),
});
}

/**
* [분석 Hook] AI로 링크를 분석하여 제목/설명/태그/폴더를 추천받습니다.
*/
export function useAnalyzeLink() {
return useMutation({
mutationFn: analyzeLink,
});
}
17 changes: 17 additions & 0 deletions frontend/src/lib/types/linkAnalysis.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export interface LinkAnalysisResponse {
title: string;
description: string | null;
suggestedTags: TagSuggestion[] | null;
suggestedFolder: FolderSuggestion | null;
}
Comment on lines +1 to +6
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 | 🟠 Major

백엔드 응답 계약과 프런트 타입이 어긋납니다.

src/main/java/com/web/SearchWeb/linkanalysis/controller/dto/LinkAnalysisDto.javasrc/main/java/com/web/SearchWeb/linkanalysis/controller/LinkAnalysisController.java 기준으로 suggestedTagsnull일 수 있고 faviconUrl은 내려오지 않습니다. 지금 선언대로면 호출부가 실제 응답보다 낙관적으로 타입을 믿게 되어 null/undefined 처리가 틀어질 수 있습니다.

🔧 제안 수정
 export interface LinkAnalysisResponse {
   title: string;
   description: string | null;
-  suggestedTags: TagSuggestion[];
+  suggestedTags: TagSuggestion[] | null;
   suggestedFolder: FolderSuggestion | null;
-  faviconUrl: string | null;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/lib/types/linkAnalysis.ts` around lines 1 - 7, The front-end
LinkAnalysisResponse interface is incompatible with the backend contract: update
the LinkAnalysisResponse declaration (interface name) so suggestedTags can be
null (use suggestedTags: TagSuggestion[] | null) and make faviconUrl
optional/absent to match the controller (e.g., faviconUrl?: string | null or
remove the property entirely), leaving suggestedFolder as FolderSuggestion |
null; adjust any call sites that assume non-null suggestedTags or a present
faviconUrl accordingly.


export interface TagSuggestion {
tagName: string;
isExisting: boolean;
}

export interface FolderSuggestion {
memberFolderId: number | null;
folderName: string;
isExisting: boolean;
}
2 changes: 2 additions & 0 deletions lombok.config
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Lombok이 생성자 주입 시 필드의 @Qualifier 어노테이션을 생성자 파라미터로 복사하도록 설정.
lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.web.SearchWeb.bookmark.error;

import com.web.SearchWeb.config.exception.BusinessException;
import com.web.SearchWeb.config.exception.ErrorCode;
import lombok.Getter;

@Getter
public class BookmarkException extends BusinessException {

private BookmarkException(ErrorCode errorCode) {
super(errorCode);
}

public static BookmarkException of(ErrorCode errorCode) {
return new BookmarkException(errorCode);
}
}
Loading
Loading