feat: connect search and project settings
This commit is contained in:
@@ -267,6 +267,52 @@
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.search-results-dropdown {
|
||||
width: min(680px, calc(100vw - 32px));
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-2);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-control);
|
||||
background: var(--color-panel);
|
||||
box-shadow: 0 12px 32px rgba(29, 33, 41, 0.18);
|
||||
}
|
||||
|
||||
.search-result-item {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
gap: var(--space-1);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
border: 0;
|
||||
border-radius: var(--radius-control);
|
||||
color: var(--color-text);
|
||||
background: transparent;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.search-result-item:hover,
|
||||
.search-result-item:focus-visible {
|
||||
background: var(--color-soft-blue);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.search-result-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.search-result-type {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.search-result-snippet {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.topbar-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
@@ -11,15 +11,18 @@ import {
|
||||
createTask,
|
||||
fetchProjectWorkspace,
|
||||
fetchProjects,
|
||||
updateProject,
|
||||
updateTask,
|
||||
uploadSource,
|
||||
} from '../api/projects'
|
||||
import type { SearchResultDTO } from '../api/search'
|
||||
import { LoginPage } from '../pages/login'
|
||||
import { ProjectActionModals, type CronDraft, type ProjectActionModal, type ProjectDraft, type SourceDraft, type TaskDraft } from '../pages/projects/project-action-modals'
|
||||
import type { ProjectSettingsUpdate } from '../pages/projects/project-sidebar'
|
||||
import { ProjectPage } from '../pages/workspace-home'
|
||||
import type { WorkspaceTaskUpdate } from '../pages/workspace-body'
|
||||
import type { ChannelKey, Project, ProjectWorkspace, Screen, Theme, WorkbenchView } from '../pages/projects/project-types'
|
||||
import { useWorkbenchSearch } from './use-workbench-search'
|
||||
|
||||
function App() {
|
||||
const [screen, setScreen] = useState<Screen>('login')
|
||||
@@ -34,6 +37,7 @@ function App() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [actionLoading, setActionLoading] = useState(false)
|
||||
const [activeModal, setActiveModal] = useState<ProjectActionModal>(null)
|
||||
const workspaceSearch = useWorkbenchSearch(session)
|
||||
|
||||
const dark = theme === 'dark'
|
||||
const activeWorkspace = workspaces.find((workspace) => workspace.project.id === activeProjectID) ?? workspaces[0]
|
||||
@@ -210,23 +214,35 @@ function App() {
|
||||
}, '任务已更新')
|
||||
}
|
||||
|
||||
function handleUpdateProject(update: ProjectSettingsUpdate) {
|
||||
setWorkspaces((current) =>
|
||||
current.map((workspace) => {
|
||||
if (workspace.project.id !== update.projectId) return workspace
|
||||
const nextProject = {
|
||||
...workspace.project,
|
||||
name: update.name,
|
||||
identifier: update.identifier,
|
||||
icon: update.icon,
|
||||
background: update.background,
|
||||
description: update.description,
|
||||
short: projectIconLabel(update.icon, update.identifier || update.name.slice(0, 2)),
|
||||
color: projectColor(update.background, workspace.project.color),
|
||||
}
|
||||
return { ...workspace, project: nextProject }
|
||||
}),
|
||||
)
|
||||
async function handleUpdateProject(update: ProjectSettingsUpdate) {
|
||||
const currentSession = requireSession()
|
||||
await updateProject(currentSession, update.projectId, {
|
||||
name: update.name,
|
||||
identifier: update.identifier,
|
||||
icon: update.icon,
|
||||
background: update.background,
|
||||
description: update.description,
|
||||
})
|
||||
await loadWorkspaces(currentSession, update.projectId)
|
||||
Message.success('项目设置已更新')
|
||||
}
|
||||
|
||||
function handleSelectSearchResult(result: SearchResultDTO) {
|
||||
const owningWorkspace = workspaces.find((workspace) => workspace.project.id === result.projectId)
|
||||
if (!owningWorkspace) {
|
||||
Message.warning('该搜索结果所在项目当前不可访问')
|
||||
return
|
||||
}
|
||||
const target = searchResultTarget(result.type)
|
||||
if (!target) {
|
||||
Message.warning('该搜索结果暂不支持导航')
|
||||
return
|
||||
}
|
||||
setActiveProjectID(owningWorkspace.project.id)
|
||||
setActiveView('project')
|
||||
setActiveChannel(target.channel)
|
||||
setActiveTaskID(target.openTask ? result.id : null)
|
||||
if (result.type === 'note') setSelectedItem(result.title)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -267,6 +283,13 @@ function App() {
|
||||
onUpdateWorkspaceTask={handleUpdateWorkspaceTask}
|
||||
onUpdateProject={handleUpdateProject}
|
||||
onCreateProjectTag={handleCreateProjectTag}
|
||||
searchQuery={workspaceSearch.query}
|
||||
searchLoading={workspaceSearch.loading}
|
||||
searchSearched={workspaceSearch.searched}
|
||||
searchResults={workspaceSearch.results}
|
||||
onSearchQueryChange={workspaceSearch.onQueryChange}
|
||||
onSearch={() => void workspaceSearch.onSearch()}
|
||||
onSelectSearchResult={handleSelectSearchResult}
|
||||
/>
|
||||
) : (
|
||||
<Spin loading />
|
||||
@@ -286,18 +309,11 @@ function App() {
|
||||
)
|
||||
}
|
||||
|
||||
function projectIconLabel(icon: string, fallback: string) {
|
||||
const trimmed = icon.trim()
|
||||
if (!trimmed) return fallback
|
||||
const chars = Array.from(trimmed)
|
||||
if (chars.length <= 2) return trimmed
|
||||
return chars.slice(0, 2).join('').toUpperCase()
|
||||
}
|
||||
|
||||
function projectColor(background: string, fallback: string) {
|
||||
const trimmed = background.trim()
|
||||
if (trimmed.startsWith('#') || trimmed.startsWith('rgb') || trimmed.startsWith('hsl')) return trimmed
|
||||
return fallback
|
||||
function searchResultTarget(type: string): { channel: ChannelKey; openTask: boolean } | null {
|
||||
if (type === 'project') return { channel: 'overview', openTask: false }
|
||||
if (type === 'task') return { channel: 'tasks', openTask: true }
|
||||
if (type === 'note') return { channel: 'notes', openTask: false }
|
||||
return null
|
||||
}
|
||||
|
||||
export default App
|
||||
|
||||
58
apps/web_v1/src/app/use-workbench-search.ts
Normal file
58
apps/web_v1/src/app/use-workbench-search.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { Message } from '@arco-design/web-react'
|
||||
import type { ApiSession } from '../api/client'
|
||||
import { searchWorkspace, type SearchResultDTO } from '../api/search'
|
||||
|
||||
export function useWorkbenchSearch(session: ApiSession | null) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState<SearchResultDTO[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [searched, setSearched] = useState(false)
|
||||
const requestSequence = useRef(0)
|
||||
|
||||
function changeQuery(value: string) {
|
||||
requestSequence.current += 1
|
||||
setQuery(value)
|
||||
setLoading(false)
|
||||
setSearched(false)
|
||||
}
|
||||
|
||||
async function submitSearch() {
|
||||
const requestID = ++requestSequence.current
|
||||
const trimmedQuery = query.trim()
|
||||
if (!trimmedQuery) {
|
||||
setResults([])
|
||||
setSearched(false)
|
||||
Message.warning('请输入搜索关键词')
|
||||
return
|
||||
}
|
||||
if (!session) {
|
||||
Message.error('未登录或登录已失效')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setSearched(false)
|
||||
try {
|
||||
const response = await searchWorkspace(session, trimmedQuery)
|
||||
if (requestID !== requestSequence.current) return
|
||||
setResults(response.items)
|
||||
setSearched(true)
|
||||
} catch (error) {
|
||||
if (requestID !== requestSequence.current) return
|
||||
setResults([])
|
||||
Message.error(error instanceof Error ? error.message : '搜索失败,请稍后重试')
|
||||
} finally {
|
||||
if (requestID === requestSequence.current) setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
query,
|
||||
results,
|
||||
loading,
|
||||
searched,
|
||||
onQueryChange: changeQuery,
|
||||
onSearch: submitSearch,
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Badge, Button, Divider, Input, Layout, Modal, Space, Typography } from '@arco-design/web-react'
|
||||
import { Alert, Badge, Button, Divider, Input, Layout, Modal, Space, Typography } from '@arco-design/web-react'
|
||||
import { IconApps, IconDashboard, IconMore, IconPlus, IconSettings } from '@arco-design/web-react/icon'
|
||||
import type { ChannelKey, ProjectChannel, ProjectWorkspace, WorkbenchView } from './project-types'
|
||||
|
||||
@@ -29,11 +29,13 @@ export function ProjectSidebar({
|
||||
activeChannel: ChannelKey
|
||||
onSelectChannel: (key: ChannelKey) => void
|
||||
onSelectItem: (title: string) => void
|
||||
onUpdateProject: (update: ProjectSettingsUpdate) => void
|
||||
onUpdateProject: (update: ProjectSettingsUpdate) => Promise<void>
|
||||
}) {
|
||||
const workspaceMode = activeView === 'workspace'
|
||||
const channels = activeWorkspace.channels
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [settingsSaving, setSettingsSaving] = useState(false)
|
||||
const [settingsError, setSettingsError] = useState('')
|
||||
const [draft, setDraft] = useState({
|
||||
name: activeWorkspace.project.name,
|
||||
identifier: activeWorkspace.project.identifier,
|
||||
@@ -44,6 +46,7 @@ export function ProjectSidebar({
|
||||
|
||||
useEffect(() => {
|
||||
if (!settingsOpen) return
|
||||
setSettingsError('')
|
||||
setDraft({
|
||||
name: activeWorkspace.project.name,
|
||||
identifier: activeWorkspace.project.identifier,
|
||||
@@ -61,7 +64,7 @@ export function ProjectSidebar({
|
||||
{workspaceMode ? <IconApps /> : <span className="project-title-icon">{projectIcon}</span>}
|
||||
<Title heading={5}>{workspaceMode ? '工作台' : activeWorkspace.project.name}</Title>
|
||||
</Space>
|
||||
<Button icon={<IconSettings />} size="mini" onClick={() => setSettingsOpen(true)} />
|
||||
<Button aria-label="编辑项目设置" icon={<IconSettings />} size="mini" onClick={() => setSettingsOpen(true)} />
|
||||
</div>
|
||||
|
||||
<div className="channel-list">
|
||||
@@ -113,20 +116,34 @@ export function ProjectSidebar({
|
||||
className="action-modal"
|
||||
title="编辑项目"
|
||||
visible={settingsOpen}
|
||||
onCancel={() => setSettingsOpen(false)}
|
||||
onOk={() => {
|
||||
onUpdateProject({
|
||||
projectId: activeWorkspace.project.id,
|
||||
name: draft.name.trim() || activeWorkspace.project.name,
|
||||
identifier: draft.identifier.trim(),
|
||||
icon: draft.icon.trim(),
|
||||
background: draft.background.trim(),
|
||||
description: draft.description.trim(),
|
||||
})
|
||||
setSettingsOpen(false)
|
||||
confirmLoading={settingsSaving}
|
||||
maskClosable={!settingsSaving}
|
||||
cancelButtonProps={{ disabled: settingsSaving }}
|
||||
onCancel={() => {
|
||||
if (!settingsSaving) setSettingsOpen(false)
|
||||
}}
|
||||
onOk={async () => {
|
||||
setSettingsSaving(true)
|
||||
setSettingsError('')
|
||||
try {
|
||||
await onUpdateProject({
|
||||
projectId: activeWorkspace.project.id,
|
||||
name: draft.name.trim() || activeWorkspace.project.name,
|
||||
identifier: draft.identifier.trim(),
|
||||
icon: draft.icon.trim(),
|
||||
background: draft.background.trim(),
|
||||
description: draft.description.trim(),
|
||||
})
|
||||
setSettingsOpen(false)
|
||||
} catch (error) {
|
||||
setSettingsError(error instanceof Error ? error.message : '项目设置保存失败,请稍后重试')
|
||||
} finally {
|
||||
setSettingsSaving(false)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Space direction="vertical" size={12} className="action-form">
|
||||
{settingsError && <Alert className="project-settings-error" type="error" content={settingsError} />}
|
||||
<label>
|
||||
<Text>名称</Text>
|
||||
<Input value={draft.name} onChange={(name) => setDraft((value) => ({ ...value, name }))} />
|
||||
|
||||
@@ -1,23 +1,102 @@
|
||||
import { Button, Input, Layout, Space, Typography } from '@arco-design/web-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button, Dropdown, Input, Layout, List, Space, Typography } from '@arco-design/web-react'
|
||||
import { IconApps, IconMoon, IconSearch, IconSun } from '@arco-design/web-react/icon'
|
||||
import type { SearchResultDTO } from '../../api/search'
|
||||
import type { Theme } from './project-types'
|
||||
|
||||
const { Header } = Layout
|
||||
const { Text } = Typography
|
||||
|
||||
export function ProjectTopbar({ theme, onToggleTheme }: { theme: Theme; onToggleTheme: () => void }) {
|
||||
export function ProjectTopbar({
|
||||
theme,
|
||||
onToggleTheme,
|
||||
query,
|
||||
loading,
|
||||
searched,
|
||||
results,
|
||||
onQueryChange,
|
||||
onSearch,
|
||||
onSelectSearchResult,
|
||||
}: {
|
||||
theme: Theme
|
||||
onToggleTheme: () => void
|
||||
query: string
|
||||
loading: boolean
|
||||
searched: boolean
|
||||
results: SearchResultDTO[]
|
||||
onQueryChange: (value: string) => void
|
||||
onSearch: () => void
|
||||
onSelectSearchResult: (result: SearchResultDTO) => void
|
||||
}) {
|
||||
const desktop = isDesktopRuntime()
|
||||
const [popupVisible, setPopupVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && searched) setPopupVisible(true)
|
||||
}, [loading, results, searched])
|
||||
|
||||
const submitSearch = () => {
|
||||
setPopupVisible(false)
|
||||
onSearch()
|
||||
}
|
||||
|
||||
const searchResults = (
|
||||
<div className="search-results-dropdown" aria-label="搜索结果">
|
||||
<List<SearchResultDTO>
|
||||
size="small"
|
||||
bordered={false}
|
||||
dataSource={results}
|
||||
noDataElement="未找到匹配结果"
|
||||
render={(result) => (
|
||||
<List.Item key={`${result.type}:${result.id}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="search-result-item"
|
||||
onClick={() => {
|
||||
setPopupVisible(false)
|
||||
onSelectSearchResult(result)
|
||||
}}
|
||||
>
|
||||
<span className="search-result-heading">
|
||||
<Text>{result.title}</Text>
|
||||
<Text className="search-result-type" type="secondary">{searchTypeLabel(result.type)}</Text>
|
||||
</span>
|
||||
{result.snippet && <Text className="search-result-snippet" type="secondary">{result.snippet}</Text>}
|
||||
</button>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<Header className="topbar">
|
||||
<div className="brand-lockup">
|
||||
<img className="brand-icon" src="/senlinai-icon.svg" alt="" aria-hidden="true" />
|
||||
<img className="brand-icon" src="/senlinai-icon.svg" alt="" />
|
||||
<Text className="brand-name">森林AI</Text>
|
||||
</div>
|
||||
<div className="global-search" role="search">
|
||||
<Input size="large" prefix={<IconSearch />} placeholder="搜索项目、频道、文档、任务、联系人..." />
|
||||
<Button size="large" type="primary">搜索</Button>
|
||||
</div>
|
||||
<Dropdown
|
||||
droplist={searchResults}
|
||||
popupVisible={popupVisible && searched}
|
||||
trigger="click"
|
||||
position="bl"
|
||||
onVisibleChange={(visible) => setPopupVisible(visible && searched)}
|
||||
>
|
||||
<div className="global-search" role="search">
|
||||
<Input
|
||||
size="large"
|
||||
prefix={<IconSearch />}
|
||||
placeholder="搜索项目、任务和笔记"
|
||||
value={query}
|
||||
onChange={(value) => {
|
||||
setPopupVisible(false)
|
||||
onQueryChange(value)
|
||||
}}
|
||||
onPressEnter={submitSearch}
|
||||
/>
|
||||
<Button size="large" type="primary" loading={loading} onClick={submitSearch}>搜索</Button>
|
||||
</div>
|
||||
</Dropdown>
|
||||
<Space className="topbar-actions">
|
||||
<Button aria-label={theme === 'dark' ? '切换浅色模式' : '切换深色模式'} icon={theme === 'dark' ? <IconSun /> : <IconMoon />} onClick={onToggleTheme} />
|
||||
{desktop && (
|
||||
@@ -32,6 +111,12 @@ export function ProjectTopbar({ theme, onToggleTheme }: { theme: Theme; onToggle
|
||||
)
|
||||
}
|
||||
|
||||
function searchTypeLabel(type: SearchResultDTO['type']) {
|
||||
if (type === 'project') return '项目'
|
||||
if (type === 'task') return '任务'
|
||||
return '笔记'
|
||||
}
|
||||
|
||||
function isDesktopRuntime() {
|
||||
return typeof window !== 'undefined' && '__TAURI__' in window
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ProjectRail } from './projects/project-rail'
|
||||
import { ProjectSidebar, type ProjectSettingsUpdate } from './projects/project-sidebar'
|
||||
import { ProjectStatusbar } from './projects/project-statusbar'
|
||||
import { ProjectTopbar } from './projects/project-topbar'
|
||||
import type { SearchResultDTO } from '../api/search'
|
||||
import type { ChannelKey, Project, ProjectWorkspace, Theme, WorkbenchView } from './projects/project-types'
|
||||
|
||||
const { Content } = Layout
|
||||
@@ -33,6 +34,13 @@ export function ProjectPage({
|
||||
onUpdateWorkspaceTask,
|
||||
onUpdateProject,
|
||||
onCreateProjectTag,
|
||||
searchQuery,
|
||||
searchLoading,
|
||||
searchSearched,
|
||||
searchResults,
|
||||
onSearchQueryChange,
|
||||
onSearch,
|
||||
onSelectSearchResult,
|
||||
}: {
|
||||
activeView: WorkbenchView
|
||||
activeWorkspace: ProjectWorkspace
|
||||
@@ -53,8 +61,15 @@ export function ProjectPage({
|
||||
onUploadSource: () => void
|
||||
onCreateCronPlan: () => void
|
||||
onUpdateWorkspaceTask: (update: WorkspaceTaskUpdate) => void
|
||||
onUpdateProject: (update: ProjectSettingsUpdate) => void
|
||||
onUpdateProject: (update: ProjectSettingsUpdate) => Promise<void>
|
||||
onCreateProjectTag: (name: string) => void
|
||||
searchQuery: string
|
||||
searchLoading: boolean
|
||||
searchSearched: boolean
|
||||
searchResults: SearchResultDTO[]
|
||||
onSearchQueryChange: (value: string) => void
|
||||
onSearch: () => void
|
||||
onSelectSearchResult: (result: SearchResultDTO) => void
|
||||
}) {
|
||||
const isProject = activeView === 'project'
|
||||
const projects = workspaces.map((workspace) => workspace.project)
|
||||
@@ -72,7 +87,17 @@ export function ProjectPage({
|
||||
|
||||
return (
|
||||
<Layout className="workbench-shell">
|
||||
<ProjectTopbar theme={theme} onToggleTheme={onToggleTheme} />
|
||||
<ProjectTopbar
|
||||
theme={theme}
|
||||
onToggleTheme={onToggleTheme}
|
||||
query={searchQuery}
|
||||
loading={searchLoading}
|
||||
searched={searchSearched}
|
||||
results={searchResults}
|
||||
onQueryChange={onSearchQueryChange}
|
||||
onSearch={onSearch}
|
||||
onSelectSearchResult={onSelectSearchResult}
|
||||
/>
|
||||
|
||||
<Layout className="workbench-main" hasSider>
|
||||
<ProjectRail
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"senlinai-agent/backend/internal/logic/files"
|
||||
"senlinai-agent/backend/internal/logic/inbox"
|
||||
"senlinai-agent/backend/internal/logic/projects"
|
||||
"senlinai-agent/backend/internal/logic/search"
|
||||
"senlinai-agent/backend/internal/logic/tasks"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
@@ -31,8 +32,8 @@ func main() {
|
||||
taskHandler := tasks.NewHandler(taskService)
|
||||
fileHandler := files.NewHandler(fileService, cfg.MaxUploadBytes)
|
||||
inboxHandler := inbox.NewHandler(inbox.NewService(inbox.StaticAnalyzer{}))
|
||||
searchHandler := search.NewHandler(search.NewService(models.DBService))
|
||||
authHandler := auth.NewHandler(authService)
|
||||
// search 与 AI 当前只有 service,尚无 HTTP registrar;后续功能任务应在实现真实契约后从此处注入,不能注册伪端点。
|
||||
appRouter := httpx.NewProtectedRouter(
|
||||
cfg,
|
||||
authService.VerifySession,
|
||||
@@ -43,6 +44,7 @@ func main() {
|
||||
taskHandler,
|
||||
fileHandler,
|
||||
inboxHandler,
|
||||
searchHandler,
|
||||
)
|
||||
if err := appRouter.Run(":" + cfg.Port); err != nil {
|
||||
log.Fatal(err)
|
||||
|
||||
@@ -99,7 +99,12 @@ func (h *Handler) updateProject(c *gin.Context) {
|
||||
writeProjectError(c, err)
|
||||
return
|
||||
}
|
||||
c.Status(http.StatusNoContent)
|
||||
project, err := h.service.GetProject(userID, identity)
|
||||
if err != nil {
|
||||
writeProjectError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, project)
|
||||
}
|
||||
|
||||
func (h *Handler) workspace(c *gin.Context) {
|
||||
|
||||
@@ -128,8 +128,16 @@ func TestUpdateProject(t *testing.T) {
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusNoContent, rec.Code)
|
||||
require.Empty(t, rec.Body.String())
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
|
||||
require.ElementsMatch(t, []string{"id", "name", "identifier", "icon", "background", "description"}, mapKeys(payload))
|
||||
require.Equal(t, project.Identity, payload["id"])
|
||||
require.Equal(t, "Alpha Next", payload["name"])
|
||||
require.Equal(t, "ALPHA-NEXT", payload["identifier"])
|
||||
require.Equal(t, "tree", payload["icon"])
|
||||
require.Equal(t, "#0FC6C2", payload["background"])
|
||||
require.Equal(t, "更新后的项目说明", payload["description"])
|
||||
var updated models.SenlinAgentProject
|
||||
require.NoError(t, models.DBService.First(&updated, project.ID).Error)
|
||||
require.Equal(t, "Alpha Next", updated.Name)
|
||||
@@ -161,7 +169,15 @@ func TestUpdateProject(t *testing.T) {
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusNoContent, rec.Code)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var payload ProjectDTO
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
|
||||
require.Equal(t, project.Identity, payload.ID)
|
||||
require.Equal(t, originalName, payload.Name)
|
||||
require.Equal(t, originalIdentifier, payload.Identifier)
|
||||
require.Empty(t, payload.Icon)
|
||||
require.Empty(t, payload.Background)
|
||||
require.Empty(t, payload.Description)
|
||||
var updated models.SenlinAgentProject
|
||||
require.NoError(t, models.DBService.First(&updated, project.ID).Error)
|
||||
require.Equal(t, originalName, updated.Name)
|
||||
|
||||
54
backend/internal/logic/search/handlers.go
Normal file
54
backend/internal/logic/search/handlers.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"senlinai-agent/backend/internal/httpx"
|
||||
"senlinai-agent/backend/internal/logic/auth"
|
||||
)
|
||||
|
||||
// Handler 将关键词搜索服务注册为受认证的 HTTP 接口。
|
||||
type Handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
// SearchResponseDTO 统一包装关键词搜索结果,空结果保持为空数组而不是 null。
|
||||
type SearchResponseDTO struct {
|
||||
Items []SearchResultDTO `json:"items"`
|
||||
}
|
||||
|
||||
// NewHandler 创建搜索 HTTP registrar。
|
||||
func NewHandler(service *Service) *Handler {
|
||||
return &Handler{service: service}
|
||||
}
|
||||
|
||||
// Register 将搜索接口注册到上层提供的 /api/v1 路由组。
|
||||
func (h *Handler) Register(router gin.IRouter) {
|
||||
router.GET("/search", h.search)
|
||||
}
|
||||
|
||||
func (h *Handler) search(c *gin.Context) {
|
||||
userID, ok := auth.CurrentUserID(c)
|
||||
if !ok {
|
||||
httpx.Error(c, http.StatusUnauthorized, "unauthorized", "未登录或登录已失效")
|
||||
return
|
||||
}
|
||||
query := strings.TrimSpace(c.Query("q"))
|
||||
if query == "" {
|
||||
httpx.Error(c, http.StatusBadRequest, "invalid_query", "请输入搜索关键词")
|
||||
return
|
||||
}
|
||||
items, err := h.service.Search(userID, query)
|
||||
if err != nil {
|
||||
log.Printf("search failed for user %d: %v", userID, err)
|
||||
httpx.Error(c, http.StatusInternalServerError, "internal_error", "搜索失败,请稍后重试")
|
||||
return
|
||||
}
|
||||
if items == nil {
|
||||
items = []SearchResultDTO{}
|
||||
}
|
||||
c.JSON(http.StatusOK, SearchResponseDTO{Items: items})
|
||||
}
|
||||
161
backend/internal/logic/search/handlers_test.go
Normal file
161
backend/internal/logic/search/handlers_test.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
"senlinai-agent/backend/internal/config"
|
||||
"senlinai-agent/backend/internal/httpx"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
func TestSearchHandlerRejectsBlankQuery(t *testing.T) {
|
||||
router, _ := newSearchHandlerTestRouter(t, 1)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/search?q=%20%20", nil)
|
||||
req.Header.Set("Authorization", "Bearer test-token")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
var payload httpx.ErrorEnvelope
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
|
||||
require.Equal(t, "invalid_query", payload.Error.Code)
|
||||
require.Equal(t, "请输入搜索关键词", payload.Error.Message)
|
||||
}
|
||||
|
||||
func TestSearchHandlerReturnsCamelCaseIdentityResults(t *testing.T) {
|
||||
database := newSearchHandlerTestDB(t)
|
||||
owner := createSearchUser(t, database, "owner@example.com")
|
||||
project := models.SenlinAgentProject{OwnerID: owner.ID, Name: "回调平台", Identifier: "CALLBACK"}
|
||||
require.NoError(t, database.Create(&project).Error)
|
||||
task := models.SenlinAgentTask{ProjectID: project.ID, CreatedBy: owner.ID, Title: "回调任务", Description: "检查签名", Status: "open"}
|
||||
require.NoError(t, database.Create(&task).Error)
|
||||
note := models.SenlinAgentNote{ProjectID: project.ID, CreatedBy: owner.ID, Title: "接口说明", Markdown: "回调验签流程"}
|
||||
require.NoError(t, database.Create(¬e).Error)
|
||||
router := searchHandlerTestRouter(owner.ID)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/search?q=%E5%9B%9E%E8%B0%83", nil)
|
||||
req.Header.Set("Authorization", "Bearer test-token")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
|
||||
require.ElementsMatch(t, []string{"items"}, searchMapKeys(payload))
|
||||
items, ok := payload["items"].([]any)
|
||||
require.True(t, ok)
|
||||
require.Len(t, items, 3)
|
||||
|
||||
byType := make(map[string]map[string]any, len(items))
|
||||
for _, raw := range items {
|
||||
item, ok := raw.(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.ElementsMatch(t, []string{"id", "type", "title", "projectId", "snippet"}, searchMapKeys(item))
|
||||
byType[item["type"].(string)] = item
|
||||
}
|
||||
require.Equal(t, project.Identity, byType["project"]["id"])
|
||||
require.Equal(t, project.Identity, byType["project"]["projectId"])
|
||||
require.Equal(t, task.Identity, byType["task"]["id"])
|
||||
require.Equal(t, project.Identity, byType["task"]["projectId"])
|
||||
require.Equal(t, note.Identity, byType["note"]["id"])
|
||||
require.Equal(t, project.Identity, byType["note"]["projectId"])
|
||||
}
|
||||
|
||||
func TestSearchHandlerOnlyReturnsObjectsVisibleToCurrentUser(t *testing.T) {
|
||||
database := newSearchHandlerTestDB(t)
|
||||
owner := createSearchUser(t, database, "owner@example.com")
|
||||
viewer := createSearchUser(t, database, "viewer@example.com")
|
||||
project := models.SenlinAgentProject{OwnerID: owner.ID, Name: "保密检索词项目", Identifier: "PRIVATE"}
|
||||
require.NoError(t, database.Create(&project).Error)
|
||||
assigned := models.SenlinAgentTask{ProjectID: project.ID, CreatedBy: owner.ID, AssigneeID: &viewer.ID, Title: "检索词已指派任务", Status: "open"}
|
||||
require.NoError(t, database.Create(&assigned).Error)
|
||||
privateTask := models.SenlinAgentTask{ProjectID: project.ID, CreatedBy: owner.ID, Title: "检索词私有任务", Status: "open"}
|
||||
require.NoError(t, database.Create(&privateTask).Error)
|
||||
sharedNote := models.SenlinAgentNote{ProjectID: project.ID, CreatedBy: owner.ID, Title: "检索词共享笔记", Markdown: "已显式分享"}
|
||||
require.NoError(t, database.Create(&sharedNote).Error)
|
||||
privateNote := models.SenlinAgentNote{ProjectID: project.ID, CreatedBy: owner.ID, Title: "检索词私有笔记", Markdown: "不得泄漏"}
|
||||
require.NoError(t, database.Create(&privateNote).Error)
|
||||
otherProject := models.SenlinAgentProject{OwnerID: owner.ID, Name: "其他项目", Identifier: "OTHER"}
|
||||
require.NoError(t, database.Create(&otherProject).Error)
|
||||
crossProjectNote := models.SenlinAgentNote{ProjectID: otherProject.ID, CreatedBy: owner.ID, Title: "检索词跨项目笔记", Markdown: "伪造分享也不得泄漏"}
|
||||
require.NoError(t, database.Create(&crossProjectNote).Error)
|
||||
require.NoError(t, database.Create(&models.SenlinAgentTaskShare{
|
||||
TaskID: assigned.ID, ObjectType: "note", ObjectID: sharedNote.ID,
|
||||
}).Error)
|
||||
// 即使历史数据绕过服务层写入跨项目分享,搜索也必须在读取边界再次校验项目一致性。
|
||||
require.NoError(t, database.Create(&models.SenlinAgentTaskShare{
|
||||
TaskID: assigned.ID, ObjectType: "note", ObjectID: crossProjectNote.ID,
|
||||
}).Error)
|
||||
router := searchHandlerTestRouter(viewer.ID)
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/search?q=%E6%A3%80%E7%B4%A2%E8%AF%8D", nil)
|
||||
req.Header.Set("Authorization", "Bearer test-token")
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var payload struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
ProjectID string `json:"projectId"`
|
||||
} `json:"items"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
|
||||
require.Len(t, payload.Items, 2)
|
||||
require.ElementsMatch(t, []string{assigned.Identity, sharedNote.Identity}, []string{payload.Items[0].ID, payload.Items[1].ID})
|
||||
for _, item := range payload.Items {
|
||||
require.Equal(t, project.Identity, item.ProjectID)
|
||||
require.Contains(t, []string{"task", "note"}, item.Type)
|
||||
require.NotEqual(t, privateTask.Identity, item.ID)
|
||||
require.NotEqual(t, privateNote.Identity, item.ID)
|
||||
require.NotEqual(t, crossProjectNote.Identity, item.ID)
|
||||
require.NotEqual(t, project.Identity, item.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func newSearchHandlerTestRouter(t *testing.T, userID uint) (http.Handler, *gorm.DB) {
|
||||
t.Helper()
|
||||
database := newSearchHandlerTestDB(t)
|
||||
return searchHandlerTestRouter(userID), database
|
||||
}
|
||||
|
||||
func searchHandlerTestRouter(userID uint) http.Handler {
|
||||
return httpx.NewProtectedRouter(
|
||||
config.Config{Env: "test"},
|
||||
func(string) (uint, error) { return userID, nil },
|
||||
NewHandler(NewService()),
|
||||
)
|
||||
}
|
||||
|
||||
func newSearchHandlerTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, models.AutoMigrate(database))
|
||||
models.DBService = database
|
||||
return database
|
||||
}
|
||||
|
||||
func createSearchUser(t *testing.T, database *gorm.DB, email string) models.SenlinAgentUser {
|
||||
t.Helper()
|
||||
user := models.SenlinAgentUser{Email: email, DisplayName: email, PasswordHash: "hash"}
|
||||
require.NoError(t, database.Create(&user).Error)
|
||||
return user
|
||||
}
|
||||
|
||||
func searchMapKeys(values map[string]any) []string {
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
@@ -3,133 +3,126 @@ package search
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
const maxSearchResults = 50
|
||||
|
||||
// SearchResultDTO 是搜索接口的稳定结果,所有关联均使用公开 identity。
|
||||
type SearchResultDTO struct {
|
||||
Type string `json:"type"`
|
||||
ID uint `json:"id"`
|
||||
ProjectID uint `json:"project_id"`
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"projectId"`
|
||||
Title string `json:"title"`
|
||||
Snippet string `json:"snippet"`
|
||||
}
|
||||
|
||||
func NewService() *Service {
|
||||
return &Service{}
|
||||
func NewService(databases ...*gorm.DB) *Service {
|
||||
var database *gorm.DB
|
||||
if len(databases) > 0 {
|
||||
database = databases[0]
|
||||
}
|
||||
return &Service{db: database}
|
||||
}
|
||||
|
||||
func (s *Service) Search(userID uint, query string) ([]Result, error) {
|
||||
func (s *Service) database() *gorm.DB {
|
||||
if s.db != nil {
|
||||
return s.db
|
||||
}
|
||||
return models.DBService
|
||||
}
|
||||
|
||||
func (s *Service) Search(userID uint, query string) ([]SearchResultDTO, error) {
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return []Result{}, nil
|
||||
return []SearchResultDTO{}, nil
|
||||
}
|
||||
if models.DBService.Dialector.Name() == "postgres" {
|
||||
if s.database().Dialector.Name() == "postgres" {
|
||||
return s.searchPostgres(userID, query)
|
||||
}
|
||||
|
||||
like := "%" + query + "%"
|
||||
results := []Result{}
|
||||
like := containsPattern(query)
|
||||
results := []SearchResultDTO{}
|
||||
var projects []models.SenlinAgentProject
|
||||
if err := models.DBService.Where("owner_id = ? AND (name LIKE ? OR description LIKE ?)", userID, like, like).Find(&projects).Error; err != nil {
|
||||
if err := s.database().Where("owner_id = ? AND (name LIKE ? ESCAPE '!' OR description LIKE ? ESCAPE '!')", userID, like, like).Limit(maxSearchResults).Find(&projects).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, project := range projects {
|
||||
results = append(results, Result{Type: "project", ID: project.ID, ProjectID: project.ID, Title: project.Name, Snippet: project.Description})
|
||||
results = append(results, SearchResultDTO{Type: "project", ID: project.Identity, ProjectID: project.Identity, Title: project.Name, Snippet: project.Description})
|
||||
}
|
||||
|
||||
var tasks []models.SenlinAgentTask
|
||||
if err := models.DBService.Joins("JOIN senlin_agent_projects ON senlin_agent_projects.id = senlin_agent_tasks.project_id").
|
||||
Where("senlin_agent_projects.owner_id = ? AND (senlin_agent_tasks.title LIKE ? OR senlin_agent_tasks.description LIKE ?)", userID, like, like).
|
||||
if err := s.database().Select("senlin_agent_tasks.*").Joins("JOIN senlin_agent_projects ON senlin_agent_projects.id = senlin_agent_tasks.project_id").
|
||||
Where("(senlin_agent_projects.owner_id = ? OR senlin_agent_tasks.assignee_id = ?) AND (senlin_agent_tasks.title LIKE ? ESCAPE '!' OR senlin_agent_tasks.description LIKE ? ESCAPE '!')", userID, userID, like, like).
|
||||
Limit(maxSearchResults).
|
||||
Find(&tasks).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, task := range tasks {
|
||||
results = append(results, Result{Type: "task", ID: task.ID, ProjectID: task.ProjectID, Title: task.Title, Snippet: task.Description})
|
||||
results = append(results, SearchResultDTO{Type: "task", ID: task.Identity, ProjectID: task.ProjectIdentity, Title: task.Title, Snippet: task.Description})
|
||||
}
|
||||
|
||||
var notes []models.SenlinAgentNote
|
||||
if err := models.DBService.Joins("JOIN senlin_agent_projects ON senlin_agent_projects.id = senlin_agent_notes.project_id").
|
||||
Where("senlin_agent_projects.owner_id = ? AND (senlin_agent_notes.title LIKE ? OR senlin_agent_notes.markdown LIKE ?)", userID, like, like).
|
||||
if err := s.database().Select("senlin_agent_notes.*").Joins("JOIN senlin_agent_projects ON senlin_agent_projects.id = senlin_agent_notes.project_id").
|
||||
Where(`(senlin_agent_projects.owner_id = ? OR EXISTS (
|
||||
SELECT 1 FROM senlin_agent_task_shares
|
||||
JOIN senlin_agent_tasks ON senlin_agent_tasks.id = senlin_agent_task_shares.task_id
|
||||
WHERE senlin_agent_task_shares.object_type = 'note'
|
||||
AND senlin_agent_task_shares.object_id = senlin_agent_notes.id
|
||||
AND senlin_agent_tasks.project_id = senlin_agent_notes.project_id
|
||||
AND senlin_agent_tasks.assignee_id = ?
|
||||
)) AND (senlin_agent_notes.title LIKE ? ESCAPE '!' OR senlin_agent_notes.markdown LIKE ? ESCAPE '!')`, userID, userID, like, like).
|
||||
Limit(maxSearchResults).
|
||||
Find(¬es).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, note := range notes {
|
||||
results = append(results, Result{Type: "note", ID: note.ID, ProjectID: note.ProjectID, Title: note.Title, Snippet: note.Markdown})
|
||||
results = append(results, SearchResultDTO{Type: "note", ID: note.Identity, ProjectID: note.ProjectIdentity, Title: note.Title, Snippet: note.Markdown})
|
||||
}
|
||||
|
||||
var sources []models.SenlinAgentSource
|
||||
if err := models.DBService.Joins("JOIN senlin_agent_projects ON senlin_agent_projects.id = senlin_agent_sources.project_id").
|
||||
Where("senlin_agent_projects.owner_id = ? AND (senlin_agent_sources.title LIKE ? OR senlin_agent_sources.url LIKE ? OR senlin_agent_sources.content_text LIKE ?)", userID, like, like, like).
|
||||
Find(&sources).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, source := range sources {
|
||||
results = append(results, Result{Type: "source", ID: source.ID, ProjectID: source.ProjectID, Title: source.Title, Snippet: source.ContentText})
|
||||
}
|
||||
|
||||
var inboxItems []models.SenlinAgentInboxItem
|
||||
if err := models.DBService.Joins("JOIN senlin_agent_projects ON senlin_agent_projects.id = senlin_agent_inbox_items.project_id").
|
||||
Where("senlin_agent_projects.owner_id = ? AND (senlin_agent_inbox_items.title LIKE ? OR senlin_agent_inbox_items.body LIKE ?)", userID, like, like).
|
||||
Find(&inboxItems).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, item := range inboxItems {
|
||||
results = append(results, Result{Type: "inbox", ID: item.ID, ProjectID: item.ProjectID, Title: item.Title, Snippet: item.Body})
|
||||
}
|
||||
|
||||
var sessions []models.SenlinAgentAISession
|
||||
if err := models.DBService.Joins("JOIN senlin_agent_projects ON senlin_agent_projects.id = senlin_agent_ai_sessions.project_id").
|
||||
Where("senlin_agent_projects.owner_id = ? AND (senlin_agent_ai_sessions.title LIKE ? OR senlin_agent_ai_sessions.context LIKE ?)", userID, like, like).
|
||||
Find(&sessions).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, session := range sessions {
|
||||
results = append(results, Result{Type: "ai_session", ID: session.ID, ProjectID: session.ProjectID, Title: session.Title, Snippet: session.Context})
|
||||
if len(results) > maxSearchResults {
|
||||
results = results[:maxSearchResults]
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (s *Service) searchPostgres(userID uint, query string) ([]Result, error) {
|
||||
var results []Result
|
||||
err := models.DBService.Raw(`
|
||||
SELECT 'project' AS type, p.id, p.id AS project_id, p.name AS title, p.description AS snippet
|
||||
func (s *Service) searchPostgres(userID uint, query string) ([]SearchResultDTO, error) {
|
||||
var results []SearchResultDTO
|
||||
like := containsPattern(query)
|
||||
err := s.database().Raw(`
|
||||
SELECT 'project' AS type, p.identity AS id, p.identity AS project_id, p.name AS title, p.description AS snippet
|
||||
FROM senlin_agent_projects p
|
||||
WHERE p.owner_id = ?
|
||||
AND to_tsvector('simple', coalesce(p.name, '') || ' ' || coalesce(p.description, '')) @@ plainto_tsquery('simple', ?)
|
||||
AND (coalesce(p.name, '') ILIKE ? ESCAPE '!' OR coalesce(p.description, '') ILIKE ? ESCAPE '!')
|
||||
UNION ALL
|
||||
SELECT 'task' AS type, t.id, t.project_id, t.title, t.description AS snippet
|
||||
SELECT 'task' AS type, t.identity AS id, p.identity AS project_id, t.title, t.description AS snippet
|
||||
FROM senlin_agent_tasks t
|
||||
JOIN senlin_agent_projects p ON p.id = t.project_id
|
||||
WHERE p.owner_id = ?
|
||||
AND to_tsvector('simple', coalesce(t.title, '') || ' ' || coalesce(t.description, '')) @@ plainto_tsquery('simple', ?)
|
||||
WHERE (p.owner_id = ? OR t.assignee_id = ?)
|
||||
AND (coalesce(t.title, '') ILIKE ? ESCAPE '!' OR coalesce(t.description, '') ILIKE ? ESCAPE '!')
|
||||
UNION ALL
|
||||
SELECT 'note' AS type, n.id, n.project_id, n.title, n.markdown AS snippet
|
||||
SELECT 'note' AS type, n.identity AS id, p.identity AS project_id, n.title, n.markdown AS snippet
|
||||
FROM senlin_agent_notes n
|
||||
JOIN senlin_agent_projects p ON p.id = n.project_id
|
||||
WHERE p.owner_id = ?
|
||||
AND to_tsvector('simple', coalesce(n.title, '') || ' ' || coalesce(n.markdown, '')) @@ plainto_tsquery('simple', ?)
|
||||
UNION ALL
|
||||
SELECT 'source' AS type, s.id, s.project_id, s.title, s.content_text AS snippet
|
||||
FROM senlin_agent_sources s
|
||||
JOIN senlin_agent_projects p ON p.id = s.project_id
|
||||
WHERE p.owner_id = ?
|
||||
AND to_tsvector('simple', coalesce(s.title, '') || ' ' || coalesce(s.url, '') || ' ' || coalesce(s.content_text, '')) @@ plainto_tsquery('simple', ?)
|
||||
UNION ALL
|
||||
SELECT 'inbox' AS type, i.id, i.project_id, i.title, i.body AS snippet
|
||||
FROM senlin_agent_inbox_items i
|
||||
JOIN senlin_agent_projects p ON p.id = i.project_id
|
||||
WHERE p.owner_id = ?
|
||||
AND to_tsvector('simple', coalesce(i.title, '') || ' ' || coalesce(i.body, '')) @@ plainto_tsquery('simple', ?)
|
||||
UNION ALL
|
||||
SELECT 'ai_session' AS type, a.id, a.project_id, a.title, a.context AS snippet
|
||||
FROM senlin_agent_ai_sessions a
|
||||
JOIN senlin_agent_projects p ON p.id = a.project_id
|
||||
WHERE p.owner_id = ?
|
||||
AND to_tsvector('simple', coalesce(a.title, '') || ' ' || coalesce(a.context, '')) @@ plainto_tsquery('simple', ?)
|
||||
LIMIT 50
|
||||
`, userID, query, userID, query, userID, query, userID, query, userID, query, userID, query).Scan(&results).Error
|
||||
WHERE (p.owner_id = ? OR EXISTS (
|
||||
SELECT 1 FROM senlin_agent_task_shares ts
|
||||
JOIN senlin_agent_tasks t ON t.id = ts.task_id
|
||||
WHERE ts.object_type = 'note'
|
||||
AND ts.object_id = n.id
|
||||
AND t.project_id = n.project_id
|
||||
AND t.assignee_id = ?
|
||||
))
|
||||
AND (coalesce(n.title, '') ILIKE ? ESCAPE '!' OR coalesce(n.markdown, '') ILIKE ? ESCAPE '!')
|
||||
LIMIT ?
|
||||
`, userID, like, like, userID, userID, like, like, userID, userID, like, like, maxSearchResults).Scan(&results).Error
|
||||
return results, err
|
||||
}
|
||||
|
||||
func containsPattern(query string) string {
|
||||
escaped := strings.NewReplacer("!", "!!", "%", "!%", "_", "!_").Replace(query)
|
||||
return "%" + escaped + "%"
|
||||
}
|
||||
|
||||
61
backend/internal/logic/search/service_postgres_test.go
Normal file
61
backend/internal/logic/search/service_postgres_test.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"senlinai-agent/backend/internal/models"
|
||||
)
|
||||
|
||||
func TestPostgresSearchMatchesChinesePartialKeywords(t *testing.T) {
|
||||
database := newPostgresSearchTestDB(t)
|
||||
owner := createSearchUser(t, database, "postgres-search-owner@example.com")
|
||||
project := models.SenlinAgentProject{OwnerID: owner.ID, Name: "中文回调平台", Identifier: "PG-CALLBACK"}
|
||||
require.NoError(t, database.Create(&project).Error)
|
||||
require.NoError(t, database.Create(&models.SenlinAgentTask{ProjectID: project.ID, CreatedBy: owner.ID, Title: "回调任务", Status: "open"}).Error)
|
||||
require.NoError(t, database.Create(&models.SenlinAgentNote{ProjectID: project.ID, CreatedBy: owner.ID, Title: "说明", Markdown: "回调验签流程"}).Error)
|
||||
|
||||
results, err := NewService(database).Search(owner.ID, "回调")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results, 3)
|
||||
}
|
||||
|
||||
func TestPostgresSearchKeepsAssignmentAndExplicitShareBoundaries(t *testing.T) {
|
||||
database := newPostgresSearchTestDB(t)
|
||||
owner := createSearchUser(t, database, "postgres-privacy-owner@example.com")
|
||||
viewer := createSearchUser(t, database, "postgres-privacy-viewer@example.com")
|
||||
project := models.SenlinAgentProject{OwnerID: owner.ID, Name: "保密项目", Identifier: "PG-PRIVATE"}
|
||||
require.NoError(t, database.Create(&project).Error)
|
||||
assigned := models.SenlinAgentTask{ProjectID: project.ID, CreatedBy: owner.ID, AssigneeID: &viewer.ID, Title: "边界词已指派", Status: "open"}
|
||||
require.NoError(t, database.Create(&assigned).Error)
|
||||
require.NoError(t, database.Create(&models.SenlinAgentTask{ProjectID: project.ID, CreatedBy: owner.ID, Title: "边界词私有任务", Status: "open"}).Error)
|
||||
sharedNote := models.SenlinAgentNote{ProjectID: project.ID, CreatedBy: owner.ID, Title: "边界词共享笔记"}
|
||||
require.NoError(t, database.Create(&sharedNote).Error)
|
||||
require.NoError(t, database.Create(&models.SenlinAgentNote{ProjectID: project.ID, CreatedBy: owner.ID, Title: "边界词私有笔记"}).Error)
|
||||
require.NoError(t, database.Create(&models.SenlinAgentTaskShare{TaskID: assigned.ID, ObjectType: "note", ObjectID: sharedNote.ID}).Error)
|
||||
|
||||
results, err := NewService(database).Search(viewer.ID, "边界词")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results, 2)
|
||||
require.ElementsMatch(t, []string{assigned.Identity, sharedNote.Identity}, []string{results[0].ID, results[1].ID})
|
||||
}
|
||||
|
||||
func newPostgresSearchTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("DATABASE_URL is not configured; skipping PostgreSQL search integration test")
|
||||
}
|
||||
database, err := gorm.Open(postgres.Open(dsn), &gorm.Config{TranslateError: true})
|
||||
require.NoError(t, err)
|
||||
transaction := database.Begin()
|
||||
require.NoError(t, transaction.Error)
|
||||
t.Cleanup(func() { transaction.Rollback() })
|
||||
require.NoError(t, models.AutoMigrate(transaction))
|
||||
return transaction
|
||||
}
|
||||
@@ -26,13 +26,14 @@ func TestSearchFindsNoteBody(t *testing.T) {
|
||||
require.Equal(t, "note", results[0].Type)
|
||||
}
|
||||
|
||||
func TestSearchFindsCoreProjectObjects(t *testing.T) {
|
||||
func TestSearchOnlyReturnsNavigableProjectTaskAndNoteResults(t *testing.T) {
|
||||
database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, models.AutoMigrate(database))
|
||||
models.DBService = database
|
||||
require.NoError(t, database.Create(&models.SenlinAgentProject{ID: 1, OwnerID: 7, Name: "支付项目"}).Error)
|
||||
require.NoError(t, database.Create(&models.SenlinAgentProject{ID: 1, OwnerID: 7, Name: "webhook 支付项目"}).Error)
|
||||
require.NoError(t, database.Create(&models.SenlinAgentTask{ProjectID: 1, CreatedBy: 7, Title: "回调任务", Description: "检查 webhook"}).Error)
|
||||
require.NoError(t, database.Create(&models.SenlinAgentNote{ProjectID: 1, CreatedBy: 7, Title: "回调笔记", Markdown: "webhook 流程"}).Error)
|
||||
require.NoError(t, database.Create(&models.SenlinAgentSource{ProjectID: 1, CreatedBy: 7, Kind: "link", Title: "支付文档", URL: "https://example.com/pay", ContentText: "webhook 签名"}).Error)
|
||||
require.NoError(t, database.Create(&models.SenlinAgentInboxItem{ProjectID: 1, CreatedBy: 7, SourceType: "text", Title: "收集项", Body: "webhook 待整理"}).Error)
|
||||
require.NoError(t, database.Create(&models.SenlinAgentAISession{ProjectID: 1, CreatedBy: 7, Title: "AI 分析", Context: "webhook 问答"}).Error)
|
||||
@@ -45,8 +46,25 @@ func TestSearchFindsCoreProjectObjects(t *testing.T) {
|
||||
for _, result := range results {
|
||||
types[result.Type] = true
|
||||
}
|
||||
require.True(t, types["task"])
|
||||
require.True(t, types["source"])
|
||||
require.True(t, types["inbox"])
|
||||
require.True(t, types["ai_session"])
|
||||
require.Equal(t, map[string]bool{"project": true, "task": true, "note": true}, types)
|
||||
require.Len(t, results, 3)
|
||||
}
|
||||
|
||||
func TestSearchTreatsLikeWildcardsAsLiteralKeywords(t *testing.T) {
|
||||
database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, models.AutoMigrate(database))
|
||||
models.DBService = database
|
||||
require.NoError(t, database.Create(&models.SenlinAgentProject{ID: 1, OwnerID: 7, Name: "100%_完成", Identifier: "SPECIAL"}).Error)
|
||||
require.NoError(t, database.Create(&models.SenlinAgentProject{ID: 2, OwnerID: 7, Name: "普通项目", Identifier: "NORMAL"}).Error)
|
||||
|
||||
percentResults, err := NewService().Search(7, "%")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, percentResults, 1)
|
||||
require.Equal(t, "100%_完成", percentResults[0].Title)
|
||||
|
||||
underscoreResults, err := NewService().Search(7, "_")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, underscoreResults, 1)
|
||||
require.Equal(t, "100%_完成", underscoreResults[0].Title)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user