From 85a25ca0172f935e10944f7d93f9db365b405738 Mon Sep 17 00:00:00 2001 From: yanweidong Date: Tue, 21 Jul 2026 18:24:15 +0800 Subject: [PATCH] feat: connect inbox confirmation workflow --- apps/web_v1/scripts/structure-check.mjs | 8 +- apps/web_v1/src/App.css | 93 +++++ apps/web_v1/src/api/inbox.ts | 12 +- apps/web_v1/src/api/mappers.tsx | 2 +- apps/web_v1/src/app/App.tsx | 21 + .../pages/projects/project-channel-page.tsx | 10 +- .../src/pages/projects/project-inbox.tsx | 223 +++++++++++ .../src/pages/projects/project-types.ts | 5 + apps/web_v1/src/pages/workspace-home.tsx | 9 +- backend/internal/logic/inbox/handlers.go | 101 +++-- backend/internal/logic/inbox/service.go | 299 +++++++++++--- backend/internal/logic/inbox/service_test.go | 377 ++++++++++++++++-- backend/internal/models/identity.go | 10 + backend/internal/models/inbox_suggestion.go | 21 + backend/internal/models/new.go | 1 + 15 files changed, 1069 insertions(+), 123 deletions(-) create mode 100644 apps/web_v1/src/pages/projects/project-inbox.tsx create mode 100644 backend/internal/models/inbox_suggestion.go diff --git a/apps/web_v1/scripts/structure-check.mjs b/apps/web_v1/scripts/structure-check.mjs index 12bb6ef..dd5a009 100644 --- a/apps/web_v1/scripts/structure-check.mjs +++ b/apps/web_v1/scripts/structure-check.mjs @@ -7,6 +7,7 @@ const requiredFiles = [ 'src/pages/workspace-home.tsx', 'src/pages/workspace-explore.tsx', 'src/pages/projects/project-overview.tsx', + 'src/pages/projects/project-inbox.tsx', 'src/pages/projects/project-channel-page.tsx', 'src/pages/projects/project-tasks.tsx', 'src/pages/projects/project-ai.tsx', @@ -94,12 +95,17 @@ const inboxSource = readFileSync('src/api/inbox.ts', 'utf8') for (const path of ['/api/v1/projects/', '/api/v1/inbox/']) { if (!inboxSource.includes(path)) failures.push(`inbox API must use ${path}`) } +if (inboxSource.includes('body: { suggestions }')) failures.push('inbox confirm must not send client-authored suggestion content') +if (!inboxSource.includes('body: { suggestionIds }')) failures.push('inbox confirm must send only saved suggestion identities') + +const inboxPageSource = readFileSync('src/pages/projects/project-inbox.tsx', 'utf8') +if (!inboxPageSource.includes('确认创建')) failures.push('project inbox must expose the single confirmation action') if (existsSync('src/App.tsx')) { failures.push('legacy src/App.tsx should be removed after page split') } -for (const removed of ['src/pages/projects/project-data.tsx', 'src/pages/projects/project-inspector.tsx', 'src/pages/projects/project-inbox.tsx']) { +for (const removed of ['src/pages/projects/project-data.tsx', 'src/pages/projects/project-inspector.tsx']) { if (existsSync(removed)) { failures.push(`${removed} should be removed; frontend data must come from backend APIs`) } diff --git a/apps/web_v1/src/App.css b/apps/web_v1/src/App.css index 7476f8f..37e78d5 100644 --- a/apps/web_v1/src/App.css +++ b/apps/web_v1/src/App.css @@ -1437,6 +1437,99 @@ text-overflow: ellipsis; } +.inbox-detail .arco-card-body { + display: grid; + align-content: start; + gap: var(--space-4); +} + +.inbox-detail-head, +.inbox-confirm-bar { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--space-3); +} + +.inbox-detail-head h5, +.inbox-draft-section h6 { + margin: 0; +} + +.inbox-content, +.inbox-draft-section { + display: grid; + gap: var(--space-2); + border-top: 1px solid var(--color-border); + padding-top: var(--space-3); +} + +.inbox-content p { + margin: 0; + color: var(--color-text); + line-height: 1.7; +} + +.inbox-section-label { + font-weight: 700; +} + +.inbox-suggestion-list { + display: grid; + gap: var(--space-2); +} + +.inbox-suggestion-list .arco-checkbox { + width: 100%; + align-items: flex-start; + border: 1px solid var(--color-border); + border-radius: var(--radius-control); + padding: 10px 12px; +} + +.inbox-suggestion-copy { + min-width: 0; + display: grid; + gap: 5px; +} + +.inbox-suggestion-copy > span:first-child { + display: flex; + align-items: center; + gap: var(--space-2); +} + +.inbox-feedback { + margin: 0; +} + +.inbox-confirm-bar { + align-items: center; + border-top: 1px solid var(--color-border); + padding-top: var(--space-3); +} + +@media (max-width: 960px) { + .inbox-workspace { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (max-width: 560px) { + .project-inbox-page .overview-head, + .inbox-detail-head, + .inbox-confirm-bar { + align-items: stretch; + flex-direction: column; + } + + .inbox-detail-head .arco-btn, + .inbox-confirm-bar .arco-btn { + width: 100%; + min-height: 40px; + } +} + .agent-chat-shell { min-height: calc(100vh - 170px); display: grid; diff --git a/apps/web_v1/src/api/inbox.ts b/apps/web_v1/src/api/inbox.ts index 508dbd6..7ef3647 100644 --- a/apps/web_v1/src/api/inbox.ts +++ b/apps/web_v1/src/api/inbox.ts @@ -12,6 +12,7 @@ export type InboxItemDTO = { } export type InboxSuggestionDTO = { + id: string kind: 'task' | 'note' | 'source' title: string body: string @@ -21,6 +22,10 @@ export type AnalyzeInboxResponseDTO = { suggestions: InboxSuggestionDTO[] } +export type ConfirmInboxResponseDTO = { + createdCount: number +} + export type CaptureInboxInput = { sourceType: string title: string @@ -42,11 +47,10 @@ export async function analyzeInboxItem(session: ApiSession, inboxId: string) { }) } -export async function confirmInboxItem(session: ApiSession, inboxId: string, suggestions: InboxSuggestionDTO[]) { - return apiRequest(`/api/v1/inbox/${inboxId}/confirm`, { +export async function confirmInboxItem(session: ApiSession, inboxId: string, suggestionIds: string[]) { + return apiRequest(`/api/v1/inbox/${inboxId}/confirm`, { method: 'POST', token: session.token, - body: { suggestions }, - responseType: 'void', + body: { suggestionIds }, }) } diff --git a/apps/web_v1/src/api/mappers.tsx b/apps/web_v1/src/api/mappers.tsx index 81972ab..0c87f24 100644 --- a/apps/web_v1/src/api/mappers.tsx +++ b/apps/web_v1/src/api/mappers.tsx @@ -28,7 +28,7 @@ export function mapWorkspace(payload: ProjectWorkspaceDTO, index = 0): ProjectWo return { project, - channels: payload.channels.filter((channel) => channel.type !== 'inbox').map(mapChannel), + channels: payload.channels.map(mapChannel), tags: payload.tags.map((tag) => tag.name), recentSessions: payload.recentSessions.map(mapAISession), inbox: payload.inbox.map(mapInbox), diff --git a/apps/web_v1/src/app/App.tsx b/apps/web_v1/src/app/App.tsx index 5867200..44cfc71 100644 --- a/apps/web_v1/src/app/App.tsx +++ b/apps/web_v1/src/app/App.tsx @@ -3,6 +3,7 @@ import { ConfigProvider, Message, Spin } from '@arco-design/web-react' import '@arco-design/web-react/dist/css/arco.css' import '../App.css' import { login, setApiBaseUrl, type ApiSession } from '../api/client' +import { analyzeInboxItem, confirmInboxItem } from '../api/inbox' import { mapWorkspace } from '../api/mappers' import { createCronPlan, @@ -229,6 +230,24 @@ function App() { Message.success('项目设置已更新') } + async function handleAnalyzeInbox(inboxId: string) { + const response = await analyzeInboxItem(requireSession(), inboxId) + return response.suggestions + } + + async function handleConfirmInbox(inboxId: string, suggestionIds: string[]) { + const response = await confirmInboxItem(requireSession(), inboxId, suggestionIds) + try { + await refreshAfterAction() + return { createdCount: response.createdCount } + } catch { + return { + createdCount: response.createdCount, + refreshError: '对象已创建,但工作区刷新失败,请稍后重新进入项目', + } + } + } + function handleSelectSearchResult(result: SearchResultDTO) { const target = searchResultTarget(result.type) if (!target) { @@ -296,6 +315,8 @@ function App() { onSearchQueryChange={workspaceSearch.onQueryChange} onSearch={() => void workspaceSearch.onSearch()} onSelectSearchResult={handleSelectSearchResult} + onAnalyzeInbox={handleAnalyzeInbox} + onConfirmInbox={handleConfirmInbox} /> ) : ( diff --git a/apps/web_v1/src/pages/projects/project-channel-page.tsx b/apps/web_v1/src/pages/projects/project-channel-page.tsx index 2e1b313..5e64aa2 100644 --- a/apps/web_v1/src/pages/projects/project-channel-page.tsx +++ b/apps/web_v1/src/pages/projects/project-channel-page.tsx @@ -1,11 +1,13 @@ import { ProjectAi } from './project-ai' import { ProjectCron } from './project-cron' +import { ProjectInbox } from './project-inbox' import { ProjectNewChannel } from './project-new-channel' import { ProjectNotes } from './project-notes' import { ProjectOverview } from './project-overview' import type { ProjectTaskUpdate } from './project-task-edit-modal' import { ProjectTasks } from './project-tasks' -import type { ChannelKey, ProjectWorkspace } from './project-types' +import type { ChannelKey, InboxConfirmationOutcome, ProjectWorkspace } from './project-types' +import type { InboxSuggestionDTO } from '../../api/inbox' export function ProjectChannelPage({ activeChannel, @@ -19,6 +21,8 @@ export function ProjectChannelPage({ onCreateCronPlan, onCreateProjectTag, onUpdateTask, + onAnalyzeInbox, + onConfirmInbox, }: { activeChannel: ChannelKey activeWorkspace: ProjectWorkspace @@ -31,8 +35,12 @@ export function ProjectChannelPage({ onCreateCronPlan: () => void onCreateProjectTag: (name: string) => void onUpdateTask: (update: ProjectTaskUpdate) => void + onAnalyzeInbox: (inboxId: string) => Promise + onConfirmInbox: (inboxId: string, suggestionIds: string[]) => Promise }) { switch (activeChannel) { + case 'inbox': + return case 'tasks': return case 'ai': diff --git a/apps/web_v1/src/pages/projects/project-inbox.tsx b/apps/web_v1/src/pages/projects/project-inbox.tsx new file mode 100644 index 0000000..ea86031 --- /dev/null +++ b/apps/web_v1/src/pages/projects/project-inbox.tsx @@ -0,0 +1,223 @@ +import { useMemo, useRef, useState } from 'react' +import { Alert, Button, Card, Checkbox, Empty, Space, Tag, Typography } from '@arco-design/web-react' +import { IconCheckCircle, IconRobot } from '@arco-design/web-react/icon' +import type { InboxSuggestionDTO } from '../../api/inbox' +import type { InboxConfirmationOutcome, InboxItem, ProjectWorkspace } from './project-types' + +const { Title, Text, Paragraph } = Typography + +type ProjectInboxProps = { + activeWorkspace: ProjectWorkspace + onAnalyze: (inboxId: string) => Promise + onConfirm: (inboxId: string, suggestionIds: string[]) => Promise +} + +export function ProjectInbox({ activeWorkspace, onAnalyze, onConfirm }: ProjectInboxProps) { + const [selectedItemId, setSelectedItemId] = useState(activeWorkspace.inbox[0]?.id ?? '') + const [draftSuggestions, setDraftSuggestions] = useState([]) + const [selectedSuggestionIds, setSelectedSuggestionIds] = useState([]) + const [analyzing, setAnalyzing] = useState(false) + const [confirming, setConfirming] = useState(false) + const [error, setError] = useState('') + const [success, setSuccess] = useState('') + const [refreshWarning, setRefreshWarning] = useState('') + const [locallyConfirmedItemIds, setLocallyConfirmedItemIds] = useState([]) + const analysisGeneration = useRef(0) + const selectedItem = useMemo( + () => activeWorkspace.inbox.find((item) => item.id === selectedItemId) ?? activeWorkspace.inbox[0], + [activeWorkspace.inbox, selectedItemId], + ) + + function selectItem(item: InboxItem) { + analysisGeneration.current += 1 + setSelectedItemId(item.id) + setDraftSuggestions([]) + setSelectedSuggestionIds([]) + setError('') + setSuccess('') + setRefreshWarning('') + setAnalyzing(false) + } + + async function analyzeSelectedItem() { + if (!selectedItem || selectedItem.status !== 'open') return + setAnalyzing(true) + setError('') + setSuccess('') + setRefreshWarning('') + const generation = analysisGeneration.current + 1 + analysisGeneration.current = generation + const analyzedItemId = selectedItem.id + try { + const suggestions = await onAnalyze(analyzedItemId) + if (analysisGeneration.current !== generation || selectedItemId !== analyzedItemId) return + setDraftSuggestions(suggestions) + setSelectedSuggestionIds(suggestions.map((suggestion) => suggestion.id)) + } catch (reason) { + if (analysisGeneration.current !== generation) return + setError(reason instanceof Error ? reason.message : '分析失败,请稍后重试') + } finally { + if (analysisGeneration.current === generation) setAnalyzing(false) + } + } + + async function confirmSelectedSuggestions() { + if (!selectedItem) return + if (selectedSuggestionIds.length === 0) { + setError('请至少勾选一条建议') + return + } + setConfirming(true) + setError('') + setSuccess('') + try { + const outcome = await onConfirm(selectedItem.id, selectedSuggestionIds) + setLocallyConfirmedItemIds((current) => current.includes(selectedItem.id) ? current : [...current, selectedItem.id]) + setSuccess(`已创建 ${outcome.createdCount} 个对象`) + setRefreshWarning(outcome.refreshError ?? '') + } catch (reason) { + setError(reason instanceof Error ? reason.message : '确认创建失败,请稍后重试') + } finally { + setConfirming(false) + } + } + + function toggleSuggestion(identity: string, checked: boolean) { + setSelectedSuggestionIds((current) => checked + ? current.includes(identity) ? current : [...current, identity] + : current.filter((value) => value !== identity)) + } + + return ( +
+
+
+ Inbox 消息流 + 查看原始内容,分析为候选草稿,并在确认后创建正式对象。 +
+
+ + {activeWorkspace.inbox.length === 0 ? ( + + + + ) : ( +
+ +
+ 收集内容({activeWorkspace.inbox.length}) +
+ {activeWorkspace.inbox.map((item) => ( + + ))} +
+ + + {selectedItem ? ( + <> +
+
+ + {selectedItem.title} + + {isInboxItemProcessed(selectedItem, locallyConfirmedItemIds) ? '已处理' : '待处理'} + + + {selectedItem.meta} · {selectedItem.time} +
+ +
+ +
+ 原始内容 + {selectedItem.summary || '暂无内容'} +
+ +
+
+ 分析建议 + 仅勾选内容会在确认后创建 +
+ {draftSuggestions.length === 0 ? ( + 点击“分析内容”生成候选草稿,分析不会创建正式对象。 + ) : ( +
+ {draftSuggestions.map((suggestion) => ( + toggleSuggestion(suggestion.id, checked)} + > + + + {suggestionLabel(suggestion.kind)} + {suggestion.title} + + {suggestion.body || '暂无补充内容'} + + + ))} +
+ )} +
+ + {error && } + {success && } />} + {refreshWarning && } + +
+ 正式写入前请核对勾选项 + +
+ + ) : null} +
+
+ )} +
+ ) +} + +function isInboxItemProcessed(item: InboxItem, locallyConfirmedItemIds: string[]) { + return item.status !== 'open' || locallyConfirmedItemIds.includes(item.id) +} + +function suggestionLabel(kind: InboxSuggestionDTO['kind']) { + if (kind === 'task') return '任务' + if (kind === 'note') return '笔记' + return '资料' +} + +function suggestionColor(kind: InboxSuggestionDTO['kind']) { + if (kind === 'task') return 'arcoblue' + if (kind === 'note') return 'green' + return 'purple' +} diff --git a/apps/web_v1/src/pages/projects/project-types.ts b/apps/web_v1/src/pages/projects/project-types.ts index 405759e..52628c1 100644 --- a/apps/web_v1/src/pages/projects/project-types.ts +++ b/apps/web_v1/src/pages/projects/project-types.ts @@ -39,6 +39,11 @@ export type InboxItem = { status: string } +export type InboxConfirmationOutcome = { + createdCount: number + refreshError?: string +} + export type TaskItem = { id: string title: string diff --git a/apps/web_v1/src/pages/workspace-home.tsx b/apps/web_v1/src/pages/workspace-home.tsx index c988e48..41e9645 100644 --- a/apps/web_v1/src/pages/workspace-home.tsx +++ b/apps/web_v1/src/pages/workspace-home.tsx @@ -8,7 +8,8 @@ import { ProjectSidebar, type ProjectSettingsUpdate } from './projects/project-s 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' +import type { InboxSuggestionDTO } from '../api/inbox' +import type { ChannelKey, InboxConfirmationOutcome, Project, ProjectWorkspace, Theme, WorkbenchView } from './projects/project-types' const { Content } = Layout @@ -41,6 +42,8 @@ export function ProjectPage({ onSearchQueryChange, onSearch, onSelectSearchResult, + onAnalyzeInbox, + onConfirmInbox, }: { activeView: WorkbenchView activeWorkspace: ProjectWorkspace @@ -70,6 +73,8 @@ export function ProjectPage({ onSearchQueryChange: (value: string) => void onSearch: () => void onSelectSearchResult: (result: SearchResultDTO) => void + onAnalyzeInbox: (inboxId: string) => Promise + onConfirmInbox: (inboxId: string, suggestionIds: string[]) => Promise }) { const isProject = activeView === 'project' const projects = workspaces.map((workspace) => workspace.project) @@ -139,6 +144,8 @@ export function ProjectPage({ onCreateCronPlan={onCreateCronPlan} onCreateProjectTag={onCreateProjectTag} onUpdateTask={updateActiveProjectTask} + onAnalyzeInbox={onAnalyzeInbox} + onConfirmInbox={onConfirmInbox} /> )} diff --git a/backend/internal/logic/inbox/handlers.go b/backend/internal/logic/inbox/handlers.go index 0b50733..80d0d4d 100644 --- a/backend/internal/logic/inbox/handlers.go +++ b/backend/internal/logic/inbox/handlers.go @@ -1,17 +1,33 @@ package inbox import ( + "errors" "net/http" - "strconv" + "time" "github.com/gin-gonic/gin" + "gorm.io/gorm" + "senlinai-agent/backend/internal/httpx" "senlinai-agent/backend/internal/logic/auth" + "senlinai-agent/backend/internal/models" ) type Handler struct { service *Service } +// InboxItemDTO 隔离 Gorm 自增主键,只通过公开 identity 关联项目和条目。 +type InboxItemDTO struct { + ID string `json:"id"` + ProjectID string `json:"projectId"` + SourceType string `json:"sourceType"` + Title string `json:"title"` + Body string `json:"body"` + Status string `json:"status"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + func NewHandler(service *Service) *Handler { return &Handler{service: service} } @@ -23,68 +39,101 @@ func (h *Handler) Register(router gin.IRouter) { } func (h *Handler) capture(c *gin.Context) { - userID, ok := auth.CurrentUserID(c) + userID, ok := currentInboxUser(c) if !ok { - c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"}) return } - projectID, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project id"}) + projectIdentity, ok := httpx.IdentityParam(c, "id") + if !ok { return } var input struct { - SourceType string `json:"source_type"` + SourceType string `json:"sourceType"` Title string `json:"title"` Body string `json:"body"` } if err := c.ShouldBindJSON(&input); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + httpx.Error(c, http.StatusBadRequest, "invalid_request", "请求参数无效") return } - item, err := h.service.Capture(CaptureInput{ProjectID: uint(projectID), UserID: userID, SourceType: input.SourceType, Title: input.Title, Body: input.Body}) + item, err := h.service.Capture(CaptureInput{ + ProjectIdentity: projectIdentity, UserID: userID, SourceType: input.SourceType, Title: input.Title, Body: input.Body, + }) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + writeInboxError(c, err) return } - c.JSON(http.StatusCreated, item) + c.JSON(http.StatusCreated, inboxItemDTO(*item)) } func (h *Handler) analyze(c *gin.Context) { - userID, ok := auth.CurrentUserID(c) + userID, ok := currentInboxUser(c) if !ok { - c.JSON(http.StatusUnauthorized, gin.H{"error": "missing current user"}) return } - itemID, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid inbox id"}) + itemIdentity, ok := httpx.IdentityParam(c, "id") + if !ok { return } - suggestions, err := h.service.Analyze(uint(itemID), userID) + suggestions, err := h.service.Analyze(itemIdentity, userID) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + writeInboxError(c, err) return } c.JSON(http.StatusOK, gin.H{"suggestions": suggestions}) } func (h *Handler) confirm(c *gin.Context) { - itemID, err := strconv.ParseUint(c.Param("id"), 10, 64) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid inbox id"}) + userID, ok := currentInboxUser(c) + if !ok { + return + } + itemIdentity, ok := httpx.IdentityParam(c, "id") + if !ok { return } var input struct { - Suggestions []Suggestion `json:"suggestions"` + SuggestionIDs []string `json:"suggestionIds"` } if err := c.ShouldBindJSON(&input); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + httpx.Error(c, http.StatusBadRequest, "invalid_request", "请求参数无效") return } - if err := h.service.Confirm(uint(itemID), input.Suggestions); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + result, err := h.service.Confirm(itemIdentity, userID, input.SuggestionIDs) + if err != nil { + writeInboxError(c, err) return } - c.Status(http.StatusNoContent) + c.JSON(http.StatusOK, result) +} + +func currentInboxUser(c *gin.Context) (uint, bool) { + userID, ok := auth.CurrentUserID(c) + if !ok { + httpx.Error(c, http.StatusUnauthorized, "unauthorized", "未登录或登录已失效") + return 0, false + } + return userID, true +} + +func inboxItemDTO(item models.SenlinAgentInboxItem) InboxItemDTO { + return InboxItemDTO{ + ID: item.Identity, ProjectID: item.ProjectIdentity, SourceType: item.SourceType, + Title: item.Title, Body: item.Body, Status: item.Status, + CreatedAt: item.CreatedAt.UTC(), UpdatedAt: item.UpdatedAt.UTC(), + } +} + +func writeInboxError(c *gin.Context, err error) { + switch { + case errors.Is(err, gorm.ErrRecordNotFound): + httpx.Error(c, http.StatusNotFound, "not_found", "Inbox 条目或项目不存在") + case errors.Is(err, ErrInboxAlreadyConfirmed): + httpx.Error(c, http.StatusConflict, "conflict", "该 Inbox 条目已经确认处理") + case errors.Is(err, ErrProjectAndUserRequired), errors.Is(err, ErrSourceTypeRequired), + errors.Is(err, ErrSuggestionSelectionRequired), errors.Is(err, ErrSuggestionNotFound): + httpx.Error(c, http.StatusBadRequest, "invalid_request", "请求参数或建议选择无效") + default: + httpx.Error(c, http.StatusInternalServerError, "internal_error", "Inbox 操作失败,请稍后重试") + } } diff --git a/backend/internal/logic/inbox/service.go b/backend/internal/logic/inbox/service.go index 1d587d7..a32f588 100644 --- a/backend/internal/logic/inbox/service.go +++ b/backend/internal/logic/inbox/service.go @@ -2,25 +2,43 @@ package inbox import ( "errors" + "strings" + "github.com/google/uuid" "gorm.io/gorm" + "gorm.io/gorm/clause" "senlinai-agent/backend/internal/models" ) +var ( + ErrProjectAndUserRequired = errors.New("project and user are required") + ErrSourceTypeRequired = errors.New("source type is required") + ErrInvalidSuggestion = errors.New("invalid inbox suggestion") + ErrSuggestionSelectionRequired = errors.New("suggestion selection is required") + ErrSuggestionNotFound = errors.New("selected suggestion was not saved for inbox item") + ErrInboxAlreadyConfirmed = errors.New("inbox item already confirmed") +) + type CaptureInput struct { - ProjectID uint - UserID uint - SourceType string - Title string - Body string + ProjectIdentity string + UserID uint + SourceType string + Title string + Body string } +// Suggestion 是分析接口返回的候选草稿;ID 由服务端保存后生成,确认接口不再接受 kind/title/body。 type Suggestion struct { + ID string `json:"id"` Kind string `json:"kind"` Title string `json:"title"` Body string `json:"body"` } +type ConfirmResult struct { + CreatedCount int `json:"createdCount"` +} + type Analyzer interface { Analyze(item models.SenlinAgentInboxItem, userID uint) ([]Suggestion, error) } @@ -30,72 +48,239 @@ type StaticAnalyzer struct { } func (a StaticAnalyzer) Analyze(item models.SenlinAgentInboxItem, userID uint) ([]Suggestion, error) { - return a.Suggestions, nil + if len(a.Suggestions) > 0 { + return a.Suggestions, nil + } + subject := suggestionSubject(item) + body := strings.TrimSpace(item.Body) + if body == "" { + body = strings.TrimSpace(item.Title) + } + // 默认分析器只把用户主动收集的内容整理成可审阅草稿;它不调用 provider,也不创建正式对象。 + return []Suggestion{ + {Kind: "task", Title: "跟进:" + subject, Body: body}, + {Kind: "note", Title: subject + "整理笔记", Body: body}, + {Kind: "source", Title: subject + "背景资料", Body: body}, + }, nil } type Service struct { analyzer Analyzer + db *gorm.DB } -func NewService(analyzer Analyzer) *Service { - return &Service{analyzer: analyzer} +func NewService(analyzer Analyzer, databases ...*gorm.DB) *Service { + var database *gorm.DB + if len(databases) > 0 { + database = databases[0] + } + return &Service{analyzer: analyzer, db: database} } +func (s *Service) database() *gorm.DB { + if s.db != nil { + return s.db + } + return models.DBService +} + +// Capture 先以 owner + project identity 校验项目边界,再创建归属于当前用户的原始 Inbox 条目。 func (s *Service) Capture(input CaptureInput) (*models.SenlinAgentInboxItem, error) { - if input.ProjectID == 0 || input.UserID == 0 { - return nil, errors.New("project and user are required") + if strings.TrimSpace(input.ProjectIdentity) == "" || input.UserID == 0 { + return nil, ErrProjectAndUserRequired } - if input.SourceType == "" { - return nil, errors.New("source type is required") + sourceType := strings.TrimSpace(input.SourceType) + if sourceType == "" { + return nil, ErrSourceTypeRequired } - item := &models.SenlinAgentInboxItem{ - ProjectID: input.ProjectID, - CreatedBy: input.UserID, - SourceType: input.SourceType, - Title: input.Title, - Body: input.Body, - Status: "open", - } - return item, models.DBService.Create(item).Error -} - -func (s *Service) Analyze(itemID uint, userID uint) ([]Suggestion, error) { var item models.SenlinAgentInboxItem - if err := models.DBService.First(&item, itemID).Error; err != nil { - return nil, err - } - if s.analyzer == nil { - return []Suggestion{}, nil - } - return s.analyzer.Analyze(item, userID) -} - -func (s *Service) Confirm(itemID uint, selected []Suggestion) error { - return models.DBService.Transaction(func(tx *gorm.DB) error { - var item models.SenlinAgentInboxItem - if err := tx.First(&item, itemID).Error; err != nil { + err := s.database().Transaction(func(tx *gorm.DB) error { + var project models.SenlinAgentProject + if err := tx.Where("owner_id = ? AND identity = ?", input.UserID, input.ProjectIdentity).First(&project).Error; err != nil { return err } - sourceInboxItemID := item.ID - // Inbox 建议只有在用户确认后才创建正式对象,并把来源 ID 写入每个对象以保留可追溯性。 - for _, suggestion := range selected { - switch suggestion.Kind { - case "task": - if err := tx.Create(&models.SenlinAgentTask{ProjectID: item.ProjectID, CreatedBy: item.CreatedBy, SourceInboxItemID: &sourceInboxItemID, Title: suggestion.Title, Description: suggestion.Body}).Error; err != nil { - return err - } - case "note": - if err := tx.Create(&models.SenlinAgentNote{ProjectID: item.ProjectID, CreatedBy: item.CreatedBy, SourceInboxItemID: &sourceInboxItemID, Title: suggestion.Title, Markdown: suggestion.Body}).Error; err != nil { - return err - } - case "source": - if err := tx.Create(&models.SenlinAgentSource{ProjectID: item.ProjectID, CreatedBy: item.CreatedBy, SourceInboxItemID: &sourceInboxItemID, Kind: "link", Title: suggestion.Title, ContentText: suggestion.Body}).Error; err != nil { - return err - } - default: - return errors.New("unsupported suggestion kind") - } + item = models.SenlinAgentInboxItem{ + ProjectID: project.ID, ProjectIdentity: project.Identity, CreatedBy: input.UserID, + SourceType: sourceType, Title: strings.TrimSpace(input.Title), Body: strings.TrimSpace(input.Body), Status: "open", } - return tx.Model(&item).Update("status", "processed").Error + return tx.Create(&item).Error }) + return &item, err +} + +// Analyze 只保存候选草稿,不创建任何任务、笔记或资料;重新分析会原子替换该条目的旧草稿。 +func (s *Service) Analyze(itemIdentity string, userID uint) ([]Suggestion, error) { + item, err := findOwnedInboxItem(s.database(), itemIdentity, userID, false) + if err != nil { + return nil, err + } + if item.Status != "open" { + return nil, ErrInboxAlreadyConfirmed + } + generated := []Suggestion{} + if s.analyzer != nil { + generated, err = s.analyzer.Analyze(*item, userID) + if err != nil { + return nil, err + } + } + normalized, err := normalizeSuggestions(generated) + if err != nil { + return nil, err + } + + saved := make([]Suggestion, 0, len(normalized)) + err = s.database().Transaction(func(tx *gorm.DB) error { + locked, err := findOwnedInboxItem(tx, itemIdentity, userID, true) + if err != nil { + return err + } + if locked.Status != "open" { + return ErrInboxAlreadyConfirmed + } + if err := tx.Where("inbox_item_id = ?", locked.ID).Delete(&models.SenlinAgentInboxSuggestion{}).Error; err != nil { + return err + } + for _, suggestion := range normalized { + draft := models.SenlinAgentInboxSuggestion{ + InboxItemID: locked.ID, InboxItemIdentity: locked.Identity, CreatedBy: userID, + Kind: suggestion.Kind, Title: suggestion.Title, Body: suggestion.Body, + } + if err := tx.Create(&draft).Error; err != nil { + return err + } + saved = append(saved, Suggestion{ID: draft.Identity, Kind: draft.Kind, Title: draft.Title, Body: draft.Body}) + } + return nil + }) + return saved, err +} + +// Confirm 只按服务端保存的建议 identity 创建对象;全部写入和 Inbox 状态变更位于同一事务中。 +func (s *Service) Confirm(itemIdentity string, userID uint, selectedSuggestionIdentities []string) (ConfirmResult, error) { + selected, err := normalizeSelectedIdentities(selectedSuggestionIdentities) + if err != nil { + return ConfirmResult{}, err + } + result := ConfirmResult{} + err = s.database().Transaction(func(tx *gorm.DB) error { + item, err := findOwnedInboxItem(tx, itemIdentity, userID, true) + if err != nil { + return err + } + if item.Status != "open" { + return ErrInboxAlreadyConfirmed + } + + var saved []models.SenlinAgentInboxSuggestion + if err := tx.Where("inbox_item_id = ? AND created_by = ? AND identity IN ?", item.ID, userID, selected).Find(&saved).Error; err != nil { + return err + } + if len(saved) != len(selected) { + return ErrSuggestionNotFound + } + byIdentity := make(map[string]models.SenlinAgentInboxSuggestion, len(saved)) + for _, suggestion := range saved { + byIdentity[suggestion.Identity] = suggestion + } + for _, identity := range selected { + suggestion, ok := byIdentity[identity] + if !ok { + return ErrSuggestionNotFound + } + if err := createConfirmedObject(tx, *item, suggestion); err != nil { + return err + } + result.CreatedCount++ + } + return tx.Model(item).Update("status", "processed").Error + }) + return result, err +} + +func findOwnedInboxItem(tx *gorm.DB, identity string, userID uint, lock bool) (*models.SenlinAgentInboxItem, error) { + var item models.SenlinAgentInboxItem + query := tx.Model(&models.SenlinAgentInboxItem{}). + Joins("JOIN senlin_agent_projects ON senlin_agent_projects.id = senlin_agent_inbox_items.project_id"). + Where("senlin_agent_inbox_items.identity = ? AND senlin_agent_inbox_items.created_by = ? AND senlin_agent_projects.owner_id = ?", identity, userID, userID) + if lock { + query = query.Clauses(clause.Locking{Strength: "UPDATE"}) + } + if err := query.First(&item).Error; err != nil { + return nil, err + } + return &item, nil +} + +func normalizeSuggestions(suggestions []Suggestion) ([]Suggestion, error) { + result := make([]Suggestion, 0, len(suggestions)) + for _, suggestion := range suggestions { + kind := strings.ToLower(strings.TrimSpace(suggestion.Kind)) + title := strings.TrimSpace(suggestion.Title) + if title == "" || (kind != "task" && kind != "note" && kind != "source") { + return nil, ErrInvalidSuggestion + } + result = append(result, Suggestion{Kind: kind, Title: title, Body: strings.TrimSpace(suggestion.Body)}) + } + return result, nil +} + +func suggestionSubject(item models.SenlinAgentInboxItem) string { + value := strings.TrimSpace(item.Title) + if value == "" { + value = strings.TrimSpace(item.Body) + } + if value == "" { + return "Inbox 内容" + } + runes := []rune(value) + if len(runes) > 40 { + return string(runes[:40]) + "…" + } + return value +} + +func normalizeSelectedIdentities(values []string) ([]string, error) { + if len(values) == 0 { + return nil, ErrSuggestionSelectionRequired + } + seen := make(map[string]struct{}, len(values)) + result := make([]string, 0, len(values)) + for _, value := range values { + identity, err := uuid.Parse(strings.TrimSpace(value)) + if err != nil || identity.Version() != 7 || identity.Variant() != uuid.RFC4122 { + return nil, ErrSuggestionNotFound + } + normalized := identity.String() + if _, exists := seen[normalized]; exists { + continue + } + seen[normalized] = struct{}{} + result = append(result, normalized) + } + return result, nil +} + +func createConfirmedObject(tx *gorm.DB, item models.SenlinAgentInboxItem, suggestion models.SenlinAgentInboxSuggestion) error { + sourceInboxItemID := item.ID + switch suggestion.Kind { + case "task": + return tx.Create(&models.SenlinAgentTask{ + ProjectID: item.ProjectID, ProjectIdentity: item.ProjectIdentity, CreatedBy: item.CreatedBy, + SourceInboxItemID: &sourceInboxItemID, Title: suggestion.Title, Description: suggestion.Body, Status: "open", + }).Error + case "note": + return tx.Create(&models.SenlinAgentNote{ + ProjectID: item.ProjectID, ProjectIdentity: item.ProjectIdentity, CreatedBy: item.CreatedBy, + SourceInboxItemID: &sourceInboxItemID, Title: suggestion.Title, Markdown: suggestion.Body, + }).Error + case "source": + // 文本型 Inbox 资料不是上传文件,不构造或伪造任何本地路径。 + return tx.Create(&models.SenlinAgentSource{ + ProjectID: item.ProjectID, ProjectIdentity: item.ProjectIdentity, CreatedBy: item.CreatedBy, + SourceInboxItemID: &sourceInboxItemID, Kind: "text", Title: suggestion.Title, ContentText: suggestion.Body, + }).Error + default: + return ErrInvalidSuggestion + } } diff --git a/backend/internal/logic/inbox/service_test.go b/backend/internal/logic/inbox/service_test.go index 4c1f238..e3ba889 100644 --- a/backend/internal/logic/inbox/service_test.go +++ b/backend/internal/logic/inbox/service_test.go @@ -1,56 +1,369 @@ package inbox import ( + "bytes" + "encoding/json" + "errors" "fmt" + "net/http" + "net/http/httptest" "testing" + "github.com/gin-gonic/gin" "github.com/glebarez/sqlite" + "github.com/google/uuid" "github.com/stretchr/testify/require" "gorm.io/gorm" + "senlinai-agent/backend/internal/config" + "senlinai-agent/backend/internal/httpx" "senlinai-agent/backend/internal/models" ) -func TestAnalyzeReturnsSuggestionsWithoutCreatingObjects(t *testing.T) { - database := newTestDB(t) - service := NewService(StaticAnalyzer{ - Suggestions: []Suggestion{{Kind: "task", Title: "跟进报价", Body: "联系客户确认报价"}}, - }) - item, err := service.Capture(CaptureInput{ProjectID: 1, UserID: 1, SourceType: "text", Body: "需要跟进报价"}) - require.NoError(t, err) - - suggestions, err := service.Analyze(item.ID, 1) - - require.NoError(t, err) - require.Len(t, suggestions, 1) - var count int64 - require.NoError(t, database.Model(&models.SenlinAgentTask{}).Count(&count).Error) - require.Equal(t, int64(0), count) +type inboxTestFixture struct { + database *gorm.DB + owner models.SenlinAgentUser + other models.SenlinAgentUser + project models.SenlinAgentProject } -func TestConfirmCreatesSelectedObjectsAndKeepsInboxItem(t *testing.T) { - database := newTestDB(t) - service := NewService(StaticAnalyzer{ - Suggestions: []Suggestion{{Kind: "task", Title: "跟进报价", Body: "联系客户确认报价"}}, +type inboxSuggestionResponse struct { + ID string `json:"id"` + Kind string `json:"kind"` + Title string `json:"title"` + Body string `json:"body"` +} + +type inboxAnalyzeResponse struct { + Suggestions []inboxSuggestionResponse `json:"suggestions"` +} + +func TestStaticAnalyzerBuildsReviewableDraftsFromInboxContent(t *testing.T) { + item := models.SenlinAgentInboxItem{Title: "客户访谈", Body: "客户希望下周确认交付计划。"} + + suggestions, err := (StaticAnalyzer{}).Analyze(item, 7) + + require.NoError(t, err) + require.Len(t, suggestions, 3) + require.Equal(t, []string{"task", "note", "source"}, []string{suggestions[0].Kind, suggestions[1].Kind, suggestions[2].Kind}) + for _, suggestion := range suggestions { + require.Contains(t, suggestion.Title, "客户访谈") + require.Equal(t, item.Body, suggestion.Body) + } +} + +func TestInboxCaptureUsesOwnedUUIDv7AndCamelCaseDTO(t *testing.T) { + fixture := newInboxTestFixture(t) + router := fixture.router(fixture.owner.ID, StaticAnalyzer{}) + + response := performInboxJSON(t, router, http.MethodPost, "/api/v1/projects/"+fixture.project.Identity+"/inbox", gin.H{ + "sourceType": "text", + "title": "客户回访", + "body": "整理会议记录", }) - item, err := service.Capture(CaptureInput{ProjectID: 1, UserID: 1, SourceType: "text", Body: "需要跟进报价"}) - require.NoError(t, err) - suggestions, err := service.Analyze(item.ID, 1) - require.NoError(t, err) - err = service.Confirm(item.ID, suggestions) + require.Equal(t, http.StatusCreated, response.Code) + var payload map[string]any + require.NoError(t, json.Unmarshal(response.Body.Bytes(), &payload)) + require.ElementsMatch(t, []string{"id", "projectId", "sourceType", "title", "body", "status", "createdAt", "updatedAt"}, inboxMapKeys(payload)) + require.Equal(t, fixture.project.Identity, payload["projectId"]) + require.Equal(t, "text", payload["sourceType"]) + require.Equal(t, "open", payload["status"]) + requireUUIDv7(t, payload["id"].(string)) + + var item models.SenlinAgentInboxItem + require.NoError(t, fixture.database.Where("identity = ?", payload["id"]).First(&item).Error) + require.Equal(t, fixture.owner.ID, item.CreatedBy) + require.Equal(t, fixture.owner.Identity, item.CreatedByIdentity) + require.Equal(t, fixture.project.ID, item.ProjectID) + require.Equal(t, fixture.project.Identity, item.ProjectIdentity) +} + +func TestInboxCaptureRejectsNumericAndUnownedProjectIdentities(t *testing.T) { + fixture := newInboxTestFixture(t) + router := fixture.router(fixture.owner.ID, StaticAnalyzer{}) + + numeric := performInboxJSON(t, router, http.MethodPost, "/api/v1/projects/1/inbox", gin.H{ + "sourceType": "text", "body": "numeric path", + }) + require.Equal(t, http.StatusBadRequest, numeric.Code) + + otherProject := models.SenlinAgentProject{OwnerID: fixture.other.ID, Name: "Other", Identifier: "OTHER"} + require.NoError(t, fixture.database.Create(&otherProject).Error) + unowned := performInboxJSON(t, router, http.MethodPost, "/api/v1/projects/"+otherProject.Identity+"/inbox", gin.H{ + "sourceType": "text", "body": "not mine", + }) + require.Equal(t, http.StatusNotFound, unowned.Code) + + var count int64 + require.NoError(t, fixture.database.Model(&models.SenlinAgentInboxItem{}).Count(&count).Error) + require.Zero(t, count) +} + +func TestAnalyzeUsesInboxIdentityAndCreatesNoFormalObjects(t *testing.T) { + fixture := newInboxTestFixture(t) + item := fixture.createInbox(t, fixture.owner.ID, fixture.project.ID) + router := fixture.router(fixture.owner.ID, StaticAnalyzer{Suggestions: []Suggestion{ + {Kind: "task", Title: "跟进报价", Body: "联系客户确认报价"}, + }}) + + response := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/analyze", nil) + + require.Equal(t, http.StatusOK, response.Code) + analysis := decodeInboxAnalysis(t, response) + require.Len(t, analysis.Suggestions, 1) + require.Equal(t, "task", analysis.Suggestions[0].Kind) + require.Equal(t, "跟进报价", analysis.Suggestions[0].Title) + requireUUIDv7(t, analysis.Suggestions[0].ID) + requireFormalObjectCounts(t, fixture.database, 0, 0, 0) - require.NoError(t, err) - var tasks []models.SenlinAgentTask - require.NoError(t, database.Find(&tasks).Error) - require.Len(t, tasks, 1) - require.Equal(t, "跟进报价", tasks[0].Title) - require.NotNil(t, tasks[0].SourceInboxItemID) - require.Equal(t, item.ID, *tasks[0].SourceInboxItemID) var reloaded models.SenlinAgentInboxItem - require.NoError(t, database.First(&reloaded, item.ID).Error) + require.NoError(t, fixture.database.First(&reloaded, item.ID).Error) + require.Equal(t, "open", reloaded.Status) +} + +func TestConfirmCreatesOnlySelectedSavedSuggestionsWithInboxIdentity(t *testing.T) { + fixture := newInboxTestFixture(t) + item := fixture.createInbox(t, fixture.owner.ID, fixture.project.ID) + router := fixture.router(fixture.owner.ID, StaticAnalyzer{Suggestions: []Suggestion{ + {Kind: "task", Title: "跟进报价", Body: "联系客户确认报价"}, + {Kind: "note", Title: "会议纪要", Body: "保留讨论结论"}, + {Kind: "source", Title: "背景资料", Body: "这是一段收集内容,不是上传文件"}, + {Kind: "task", Title: "不创建的任务", Body: "未被勾选"}, + }}) + analysisResponse := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/analyze", nil) + require.Equal(t, http.StatusOK, analysisResponse.Code) + analysis := decodeInboxAnalysis(t, analysisResponse) + require.Len(t, analysis.Suggestions, 4) + selectedIDs := []string{analysis.Suggestions[0].ID, analysis.Suggestions[1].ID, analysis.Suggestions[2].ID} + + response := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/confirm", gin.H{ + "suggestionIds": selectedIDs, + }) + + require.Equal(t, http.StatusOK, response.Code) + var result struct { + CreatedCount int `json:"createdCount"` + } + require.NoError(t, json.Unmarshal(response.Body.Bytes(), &result)) + require.Equal(t, 3, result.CreatedCount) + requireFormalObjectCounts(t, fixture.database, 1, 1, 1) + + var task models.SenlinAgentTask + require.NoError(t, fixture.database.First(&task).Error) + require.Equal(t, "跟进报价", task.Title) + require.Equal(t, fixture.project.ID, task.ProjectID) + require.Equal(t, fixture.owner.ID, task.CreatedBy) + requireSourceInboxIdentity(t, item, task.SourceInboxItemID, task.SourceInboxItemIdentity) + + var note models.SenlinAgentNote + require.NoError(t, fixture.database.First(¬e).Error) + require.Equal(t, "会议纪要", note.Title) + require.Equal(t, fixture.project.ID, note.ProjectID) + require.Equal(t, fixture.owner.ID, note.CreatedBy) + requireSourceInboxIdentity(t, item, note.SourceInboxItemID, note.SourceInboxItemIdentity) + + var source models.SenlinAgentSource + require.NoError(t, fixture.database.First(&source).Error) + require.Equal(t, "背景资料", source.Title) + require.Equal(t, "text", source.Kind) + require.Empty(t, source.FilePath) + require.Equal(t, "这是一段收集内容,不是上传文件", source.ContentText) + require.Equal(t, fixture.project.ID, source.ProjectID) + require.Equal(t, fixture.owner.ID, source.CreatedBy) + requireSourceInboxIdentity(t, item, source.SourceInboxItemID, source.SourceInboxItemIdentity) + + var skipped int64 + require.NoError(t, fixture.database.Model(&models.SenlinAgentTask{}).Where("title = ?", "不创建的任务").Count(&skipped).Error) + require.Zero(t, skipped) + var reloaded models.SenlinAgentInboxItem + require.NoError(t, fixture.database.First(&reloaded, item.ID).Error) require.Equal(t, "processed", reloaded.Status) } +func TestConfirmRejectsClientForgedSuggestionsAndUnknownSuggestionIdentities(t *testing.T) { + fixture := newInboxTestFixture(t) + item := fixture.createInbox(t, fixture.owner.ID, fixture.project.ID) + router := fixture.router(fixture.owner.ID, StaticAnalyzer{Suggestions: []Suggestion{ + {Kind: "task", Title: "服务端建议", Body: "只能创建这个建议"}, + }}) + analysisResponse := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/analyze", nil) + require.Equal(t, http.StatusOK, analysisResponse.Code) + + forgedContent := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/confirm", gin.H{ + "suggestions": []gin.H{{"kind": "task", "title": "客户端伪造任务", "body": "不可信内容"}}, + }) + require.Equal(t, http.StatusBadRequest, forgedContent.Code) + + unknownIdentity, err := uuid.NewV7() + require.NoError(t, err) + unknownSuggestion := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/confirm", gin.H{ + "suggestionIds": []string{unknownIdentity.String()}, + }) + require.Equal(t, http.StatusBadRequest, unknownSuggestion.Code) + requireFormalObjectCounts(t, fixture.database, 0, 0, 0) +} + +func TestAnalyzeAndConfirmHideAnotherUsersInboxItem(t *testing.T) { + fixture := newInboxTestFixture(t) + item := fixture.createInbox(t, fixture.owner.ID, fixture.project.ID) + ownerRouter := fixture.router(fixture.owner.ID, StaticAnalyzer{Suggestions: []Suggestion{ + {Kind: "task", Title: "私有建议", Body: "不得越权确认"}, + }}) + analysis := decodeInboxAnalysis(t, performInboxJSON(t, ownerRouter, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/analyze", nil)) + require.Len(t, analysis.Suggestions, 1) + otherRouter := fixture.router(fixture.other.ID, StaticAnalyzer{}) + + unauthorizedAnalyze := performInboxJSON(t, otherRouter, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/analyze", nil) + require.Equal(t, http.StatusNotFound, unauthorizedAnalyze.Code) + unauthorizedConfirm := performInboxJSON(t, otherRouter, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/confirm", gin.H{ + "suggestionIds": []string{analysis.Suggestions[0].ID}, + }) + require.Equal(t, http.StatusNotFound, unauthorizedConfirm.Code) + requireFormalObjectCounts(t, fixture.database, 0, 0, 0) +} + +func TestConfirmIsTransactionalWhenASelectedWriteFails(t *testing.T) { + fixture := newInboxTestFixture(t) + item := fixture.createInbox(t, fixture.owner.ID, fixture.project.ID) + router := fixture.router(fixture.owner.ID, StaticAnalyzer{Suggestions: []Suggestion{ + {Kind: "task", Title: "事务任务", Body: "必须随失败回滚"}, + {Kind: "source", Title: "失败资料", Body: "模拟持久化失败"}, + }}) + analysis := decodeInboxAnalysis(t, performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/analyze", nil)) + require.Len(t, analysis.Suggestions, 2) + + const callbackName = "test:fail_inbox_source_create" + require.NoError(t, fixture.database.Callback().Create().Before("gorm:create").Register(callbackName, func(tx *gorm.DB) { + if tx.Statement.Schema != nil && tx.Statement.Schema.Name == "SenlinAgentSource" { + tx.AddError(errors.New("simulated source write failure")) + } + })) + t.Cleanup(func() { _ = fixture.database.Callback().Create().Remove(callbackName) }) + + response := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/confirm", gin.H{ + "suggestionIds": []string{analysis.Suggestions[0].ID, analysis.Suggestions[1].ID}, + }) + + require.Equal(t, http.StatusInternalServerError, response.Code) + requireFormalObjectCounts(t, fixture.database, 0, 0, 0) + var reloaded models.SenlinAgentInboxItem + require.NoError(t, fixture.database.First(&reloaded, item.ID).Error) + require.Equal(t, "open", reloaded.Status) +} + +func TestRepeatedConfirmReturnsConflictWithoutDuplicateObjects(t *testing.T) { + fixture := newInboxTestFixture(t) + item := fixture.createInbox(t, fixture.owner.ID, fixture.project.ID) + router := fixture.router(fixture.owner.ID, StaticAnalyzer{Suggestions: []Suggestion{ + {Kind: "task", Title: "只创建一次", Body: "重复确认不能复制"}, + }}) + analysis := decodeInboxAnalysis(t, performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/analyze", nil)) + body := gin.H{"suggestionIds": []string{analysis.Suggestions[0].ID}} + + first := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/confirm", body) + second := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/confirm", body) + + require.Equal(t, http.StatusOK, first.Code) + require.Equal(t, http.StatusConflict, second.Code) + var payload httpx.ErrorEnvelope + require.NoError(t, json.Unmarshal(second.Body.Bytes(), &payload)) + require.Equal(t, "conflict", payload.Error.Code) + requireFormalObjectCounts(t, fixture.database, 1, 0, 0) +} + +func (fixture inboxTestFixture) router(userID uint, analyzer Analyzer) http.Handler { + return httpx.NewProtectedRouter( + config.Config{Env: "test"}, + func(string) (uint, error) { return userID, nil }, + NewHandler(NewService(analyzer)), + ) +} + +func (fixture inboxTestFixture) createInbox(t *testing.T, userID, projectID uint) models.SenlinAgentInboxItem { + t.Helper() + item := models.SenlinAgentInboxItem{ + ProjectID: projectID, CreatedBy: userID, SourceType: "text", Title: "待整理", Body: "需要整理的原始内容", Status: "open", + } + require.NoError(t, fixture.database.Create(&item).Error) + return item +} + +func newInboxTestFixture(t *testing.T) inboxTestFixture { + t.Helper() + database := newTestDB(t) + owner := models.SenlinAgentUser{Email: t.Name() + "-owner@example.com", DisplayName: "Owner", PasswordHash: "hash"} + other := models.SenlinAgentUser{Email: t.Name() + "-other@example.com", DisplayName: "Other", PasswordHash: "hash"} + require.NoError(t, database.Create(&owner).Error) + require.NoError(t, database.Create(&other).Error) + project := models.SenlinAgentProject{OwnerID: owner.ID, Name: "Inbox Project", Identifier: "INBOX"} + require.NoError(t, database.Create(&project).Error) + return inboxTestFixture{database: database, owner: owner, other: other, project: project} +} + +func performInboxJSON(t *testing.T, router http.Handler, method, path string, payload any) *httptest.ResponseRecorder { + t.Helper() + var body bytes.Buffer + if payload != nil { + require.NoError(t, json.NewEncoder(&body).Encode(payload)) + } + request := httptest.NewRequest(method, path, &body) + request.Header.Set("Authorization", "Bearer test-token") + if payload != nil { + request.Header.Set("Content-Type", "application/json") + } + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + return response +} + +func decodeInboxAnalysis(t *testing.T, response *httptest.ResponseRecorder) inboxAnalyzeResponse { + t.Helper() + require.Equal(t, http.StatusOK, response.Code, response.Body.String()) + var payload inboxAnalyzeResponse + require.NoError(t, json.Unmarshal(response.Body.Bytes(), &payload)) + return payload +} + +func requireFormalObjectCounts(t *testing.T, database *gorm.DB, tasks, notes, sources int64) { + t.Helper() + for _, check := range []struct { + model any + want int64 + }{ + {model: &models.SenlinAgentTask{}, want: tasks}, + {model: &models.SenlinAgentNote{}, want: notes}, + {model: &models.SenlinAgentSource{}, want: sources}, + } { + var count int64 + require.NoError(t, database.Model(check.model).Count(&count).Error) + require.Equal(t, check.want, count) + } +} + +func requireSourceInboxIdentity(t *testing.T, item models.SenlinAgentInboxItem, sourceID *uint, sourceIdentity *string) { + t.Helper() + require.NotNil(t, sourceID) + require.Equal(t, item.ID, *sourceID) + require.NotNil(t, sourceIdentity) + require.Equal(t, item.Identity, *sourceIdentity) +} + +func requireUUIDv7(t *testing.T, value string) { + t.Helper() + parsed, err := uuid.Parse(value) + require.NoError(t, err) + require.Equal(t, uuid.Version(7), parsed.Version()) + require.Equal(t, uuid.RFC4122, parsed.Variant()) +} + +func inboxMapKeys(value map[string]any) []string { + keys := make([]string, 0, len(value)) + for key := range value { + keys = append(keys, key) + } + return keys +} + func newTestDB(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{}) diff --git a/backend/internal/models/identity.go b/backend/internal/models/identity.go index 1b8e27f..641bc9f 100644 --- a/backend/internal/models/identity.go +++ b/backend/internal/models/identity.go @@ -28,6 +28,16 @@ func (m *SenlinAgentInboxItem) BeforeCreate(tx *gorm.DB) error { return resolveIdentity(tx, &SenlinAgentUser{}, m.CreatedBy, &m.CreatedByIdentity) } +func (m *SenlinAgentInboxSuggestion) BeforeCreate(tx *gorm.DB) error { + if err := ensureIdentity(&m.Identity); err != nil { + return err + } + if err := resolveIdentity(tx, &SenlinAgentInboxItem{}, m.InboxItemID, &m.InboxItemIdentity); err != nil { + return err + } + return resolveIdentity(tx, &SenlinAgentUser{}, m.CreatedBy, &m.CreatedByIdentity) +} + func (m *SenlinAgentTask) BeforeCreate(tx *gorm.DB) error { if err := ensureIdentity(&m.Identity); err != nil { return err diff --git a/backend/internal/models/inbox_suggestion.go b/backend/internal/models/inbox_suggestion.go new file mode 100644 index 0000000..a06fc05 --- /dev/null +++ b/backend/internal/models/inbox_suggestion.go @@ -0,0 +1,21 @@ +package models + +import "time" + +// SenlinAgentInboxSuggestion 保存分析阶段的候选草稿;它不是任务、笔记或资料,只有用户确认后才会创建正式对象。 +type SenlinAgentInboxSuggestion struct { + ID uint `gorm:"primaryKey"` + Identity string `gorm:"type:char(36);uniqueIndex"` + InboxItemID uint `gorm:"index;not null"` + InboxItemIdentity string `gorm:"type:char(36);index"` + CreatedBy uint `gorm:"index;not null"` + CreatedByIdentity string `gorm:"type:char(36);index"` + Kind string `gorm:"not null"` + Title string `gorm:"not null"` + Body string `gorm:"type:text"` + CreatedAt time.Time +} + +func (SenlinAgentInboxSuggestion) TableName() string { + return "senlin_agent_inbox_suggestions" +} diff --git a/backend/internal/models/new.go b/backend/internal/models/new.go index 4d9752d..89fe3de 100644 --- a/backend/internal/models/new.go +++ b/backend/internal/models/new.go @@ -27,6 +27,7 @@ func AutoMigrate(database *gorm.DB) error { &SenlinAgentUser{}, &SenlinAgentProject{}, &SenlinAgentInboxItem{}, + &SenlinAgentInboxSuggestion{}, &SenlinAgentTask{}, &SenlinAgentNote{}, &SenlinAgentSource{},