feat(documents): rebuild project document workbench

This commit is contained in:
2026-07-24 12:16:02 +08:00
parent 366136cee9
commit 77beaebb30
73 changed files with 5867 additions and 1264 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -11,8 +11,20 @@
},
"dependencies": {
"@arco-design/web-react": "^2.66.16",
"@codemirror/commands": "^6.10.4",
"@codemirror/lang-markdown": "^6.5.1",
"@codemirror/state": "^6.7.1",
"@codemirror/theme-one-dark": "^6.1.3",
"@codemirror/view": "^6.43.6",
"@types/dompurify": "^3.0.5",
"dompurify": "^3.4.12",
"mammoth": "^1.12.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
"read-excel-file": "^9.3.4",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@types/node": "^24.13.2",

View File

@@ -11,7 +11,8 @@ const requiredFiles = [
'src/pages/projects/project-channel-page.tsx',
'src/pages/projects/project-tasks.tsx',
'src/pages/projects/project-ai.tsx',
'src/pages/projects/project-notes.tsx',
'src/pages/projects/project-documents.tsx',
'src/pages/projects/document-markdown-editor.tsx',
'src/pages/projects/project-cron.tsx',
'src/pages/projects/project-new-channel.tsx',
'src/pages/projects/project-rail.tsx',
@@ -25,6 +26,7 @@ const requiredFiles = [
'src/api/search.ts',
'src/api/inbox.ts',
'src/api/ai.ts',
'src/api/documents.ts',
'scripts/api-client.test.mjs',
]

View File

@@ -85,7 +85,7 @@ const visualCheckWorkspace = {
{ id: 'inbox', projectId, type: 'inbox', title: 'Inbox 消息流', icon: 'email', count: 2, url: '', sortOrder: 1 },
{ id: 'tasks', projectId, type: 'tasks', title: '工作计划', icon: 'list', count: 0, url: '', sortOrder: 1 },
{ id: 'ai', projectId, type: 'ai_sessions', title: 'AI 会话', icon: 'robot', count: 0, url: '', sortOrder: 2 },
{ id: 'notes', projectId, type: 'notes_sources', title: '笔记资料', icon: 'file', count: 0, url: '', sortOrder: 3 },
{ id: 'documents', projectId, type: 'documents', title: '笔记资料', icon: 'file', count: 0, url: '', sortOrder: 3 },
{ id: 'cron', projectId, type: 'cron', title: '计划任务', icon: 'clock', count: 0, url: '', sortOrder: 4 },
],
tags: [
@@ -160,7 +160,7 @@ const visualCheckWorkspace = {
},
],
aiSessions: [],
notesSources: [],
documents: [],
cronPlans: [],
}
@@ -182,6 +182,10 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => {
await route.fulfill({ json: visualCheckWorkspace })
return
}
if (url.pathname === `/api/v1/projects/${projectId}/documents` && route.request().method() === 'GET') {
await route.fulfill({ json: [] })
return
}
if (url.pathname === `/api/v1/projects/${projectId}/ai-sessions`) {
if (route.request().method() === 'POST') {
const input = route.request().postDataJSON()
@@ -271,7 +275,7 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => {
return
}
if (url.pathname.endsWith('/deposit') && route.request().method() === 'POST') {
await route.fulfill({ status: 201, json: { noteId: '019b0000-0000-7000-8000-000000000099', projectId } })
await route.fulfill({ status: 201, json: { documentId: '019b0000-0000-7000-8000-000000000099', projectId } })
return
}
if (url.pathname.startsWith('/api/v1/dataset-items/') && route.request().method() === 'PATCH') {
@@ -450,7 +454,7 @@ let aiSessionCheck = null
for (const channel of [
{ label: '工作计划', pageClass: 'project-tasks-page', expectedHeading: '工作计划' },
{ label: 'AI 会话', pageClass: 'project-ai-page', expectedHeading: '' },
{ label: '笔记资料', pageClass: 'project-notes-page', expectedHeading: '笔记资料' },
{ label: '笔记资料', pageClass: 'documents-canvas', expectedHeading: '' },
{ label: '计划任务', pageClass: 'project-cron-page', expectedHeading: '计划任务' },
{ label: '新建频道', pageClass: 'project-new-channel-page', expectedHeading: '新建频道' },
]) {
@@ -502,6 +506,10 @@ for (const channel of [
await page.screenshot({ path: 'test-results/project-ai-session-light.png', fullPage: true })
}
if (channel.label === '笔记资料') {
await page.screenshot({ path: 'test-results/project-documents-light.png', fullPage: true })
}
channelPageChecks.push(pageCheck)
}

View File

@@ -1400,6 +1400,386 @@
min-width: 0;
}
.documents-canvas {
position: relative;
height: calc(100vh - 90px);
min-height: 560px;
display: flex;
margin: -16px;
overflow: hidden;
background: var(--senlin-panel);
color: var(--senlin-text);
}
.documents-tree-pane {
min-width: 210px;
max-width: 420px;
display: flex;
flex: 0 0 auto;
flex-direction: column;
overflow: hidden;
border-right: 1px solid var(--senlin-border);
background: color-mix(in srgb, var(--senlin-panel) 94%, var(--senlin-bg));
}
.documents-tree-tools,
.documents-tabs-bar {
min-height: 42px;
display: flex;
align-items: center;
border-bottom: 1px solid var(--senlin-border);
}
.documents-tree-tools {
gap: 2px;
padding: 0 6px;
}
.documents-tree-spacer {
flex: 1;
}
.documents-tree {
min-height: 0;
flex: 1;
overflow: auto;
padding: 6px 0 12px;
}
.document-tree-row {
width: calc(100% - 8px);
height: 30px;
display: flex;
align-items: center;
gap: 7px;
margin: 1px 4px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--senlin-text);
font-size: 13px;
text-align: left;
cursor: default;
}
.document-tree-row:hover {
background: color-mix(in srgb, var(--senlin-primary) 8%, transparent);
}
.document-tree-row.active {
background: color-mix(in srgb, var(--senlin-primary) 14%, transparent);
color: var(--senlin-primary);
}
.document-tree-row > span:last-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.document-tree-row .arco-icon {
flex: 0 0 auto;
}
.tree-chevron {
width: 11px;
flex: 0 0 11px;
color: var(--senlin-muted);
font-size: 15px;
line-height: 1;
}
.documents-resizer {
z-index: 2;
width: 4px;
flex: 0 0 4px;
margin-left: -4px;
cursor: col-resize;
}
.documents-resizer:hover {
background: var(--senlin-primary);
}
.documents-editor-pane {
min-width: 0;
display: flex;
flex: 1;
flex-direction: column;
}
.documents-tabs-bar {
flex: 0 0 42px;
background: color-mix(in srgb, var(--senlin-panel) 97%, var(--senlin-bg));
}
.documents-tabs {
min-width: 0;
display: flex;
flex: 1;
align-self: stretch;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
}
.document-tab {
min-width: 132px;
max-width: 220px;
display: flex;
align-items: center;
gap: 7px;
border: 0;
border-right: 1px solid var(--senlin-border);
border-bottom: 2px solid transparent;
background: color-mix(in srgb, var(--senlin-bg) 50%, var(--senlin-panel));
color: var(--senlin-muted);
padding: 0 10px;
cursor: pointer;
}
.document-tab.active {
border-bottom-color: var(--senlin-primary);
background: var(--senlin-panel);
color: var(--senlin-text);
}
.document-tab > span {
min-width: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
}
.document-tab > .arco-icon:last-child {
opacity: 0;
}
.document-tab:hover > .arco-icon:last-child,
.document-tab.active > .arco-icon:last-child {
opacity: 1;
}
.save-dot {
width: 6px;
height: 6px;
flex: 0 0 6px;
border-radius: 50%;
background: transparent;
}
.save-dot.dirty {
background: #ff7d00;
}
.save-dot.saving {
background: #165dff;
animation: document-pulse 1s infinite;
}
.save-dot.saved {
background: #00b42a;
}
.save-dot.error {
background: #f53f3f;
}
@keyframes document-pulse {
50% { opacity: 0.3; }
}
.documents-tab-actions {
height: 100%;
display: flex;
flex: 0 0 auto;
align-items: center;
border-left: 1px solid var(--senlin-border);
padding: 0 5px;
}
.document-editor-body {
min-height: 0;
flex: 1;
overflow: hidden;
background: var(--senlin-panel);
}
.document-codemirror,
.document-codemirror .cm-editor {
height: 100%;
}
.document-markdown-preview,
.document-office-preview,
.document-text-preview,
.document-sheet-preview {
width: min(900px, calc(100% - 48px));
height: 100%;
box-sizing: border-box;
margin: 0 auto;
overflow: auto;
padding: 28px 34px 60px;
}
.document-markdown-preview {
font-size: 15px;
line-height: 1.75;
}
.document-markdown-preview img {
max-width: 100%;
}
.document-markdown-preview pre,
.document-text-preview {
border-radius: 6px;
background: color-mix(in srgb, var(--senlin-bg) 78%, var(--senlin-panel));
padding: 14px;
white-space: pre-wrap;
}
.document-markdown-preview table,
.document-sheet-preview table {
border-collapse: collapse;
}
.document-markdown-preview th,
.document-markdown-preview td,
.document-sheet-preview td {
border: 1px solid var(--senlin-border);
padding: 7px 10px;
}
.document-pdf-preview {
width: 100%;
height: 100%;
border: 0;
}
.document-media-preview {
width: 100%;
height: 100%;
display: grid;
place-items: center;
overflow: auto;
padding: 24px;
box-sizing: border-box;
}
.document-media-preview img,
.document-media-preview video {
max-width: 100%;
max-height: 100%;
}
.document-upload-summary {
display: grid;
gap: 6px;
border-top: 1px solid var(--senlin-border);
padding: 9px 10px 10px;
font-size: 12px;
}
.document-upload-summary > div,
.document-upload-row {
min-width: 0;
display: flex;
justify-content: space-between;
gap: 8px;
}
.document-upload-row span:first-child {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.document-upload-row button {
border: 0;
background: transparent;
color: var(--senlin-primary);
padding: 0;
font-size: 12px;
cursor: pointer;
}
.document-context-menu {
position: fixed;
z-index: 1000;
width: 150px;
display: grid;
border: 1px solid var(--senlin-border);
border-radius: 6px;
background: var(--senlin-panel);
box-shadow: 0 8px 24px rgb(0 0 0 / 16%);
padding: 5px;
}
.document-context-menu button {
display: flex;
align-items: center;
gap: 8px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--senlin-text);
padding: 8px 10px;
text-align: left;
}
.document-context-menu button:hover {
background: var(--senlin-soft-blue);
}
.document-context-menu button.danger {
color: #f53f3f;
}
.documents-mobile-switch {
display: none;
}
@media (max-width: 720px) {
.documents-canvas {
min-height: 480px;
}
.documents-canvas .documents-tree-pane,
.documents-canvas .documents-editor-pane {
width: 100% !important;
max-width: none;
}
.documents-canvas.mobile-tree .documents-editor-pane,
.documents-canvas.mobile-editor .documents-tree-pane,
.documents-canvas .documents-resizer {
display: none;
}
.documents-mobile-switch {
display: block;
border-bottom: 1px solid var(--senlin-border);
padding: 4px 8px;
}
.document-markdown-preview,
.document-office-preview,
.document-text-preview,
.document-sheet-preview {
width: 100%;
padding: 20px 18px 48px;
}
.documents-tab-actions .arco-btn {
padding: 0 7px;
}
}
.compact-card .arco-card-body {
padding: 12px 14px;
}

View File

@@ -46,7 +46,7 @@ export type DatasetItemPageDTO = {
}
export type DatasetDepositDTO = {
noteId: string
documentId: string
projectId: string
}

View File

@@ -0,0 +1,189 @@
import { ApiError, apiRequest, getApiBaseUrl, type ApiSession } from './client'
export type DocumentDTO = {
id: string
projectId: string
parentId: string | null
kind: 'folder' | 'file'
name: string
extension: string
mimeType: string
size: number
revision: number
markdown?: string
hasBlob: boolean
sourceInboxItemId: string | null
createdAt: string
updatedAt: string
}
export type DocumentOpenIntent = {
intentId: string
mode: 'create' | 'open'
projectId: string
documentId?: string
suggestedName?: string
initialMarkdown?: string
targetDirectoryId?: string
sourceType?: string
sourceId?: string
}
export type DocumentShareDTO = {
id: string
url?: string
expiresAt: string
revokedAt: string | null
}
export function listDocuments(session: ApiSession, projectId: string, signal?: AbortSignal) {
return apiRequest<DocumentDTO[]>(`/api/v1/projects/${projectId}/documents`, { token: session.token, signal })
}
export function getDocument(session: ApiSession, projectId: string, documentId: string, signal?: AbortSignal) {
return apiRequest<DocumentDTO>(`/api/v1/projects/${projectId}/documents/${documentId}`, { token: session.token, signal })
}
export function createDocumentFolder(session: ApiSession, projectId: string, name: string, parentId = '') {
return apiRequest<DocumentDTO>(`/api/v1/projects/${projectId}/documents/folders`, {
method: 'POST',
token: session.token,
body: { name, parentId },
})
}
export function createMarkdownDocument(
session: ApiSession,
projectId: string,
input: { name: string; parentId?: string; markdown?: string; sourceInboxItemId?: string },
) {
return apiRequest<DocumentDTO>(`/api/v1/projects/${projectId}/documents/markdown`, {
method: 'POST',
token: session.token,
body: input,
})
}
export function updateDocument(
session: ApiSession,
projectId: string,
documentId: string,
input: { name?: string; parentId?: string; markdown?: string; revision?: number },
) {
return apiRequest<DocumentDTO>(`/api/v1/projects/${projectId}/documents/${documentId}`, {
method: 'PATCH',
token: session.token,
body: input,
})
}
export function deleteDocument(session: ApiSession, projectId: string, documentId: string) {
return apiRequest<void>(`/api/v1/projects/${projectId}/documents/${documentId}`, {
method: 'DELETE',
token: session.token,
responseType: 'void',
})
}
export function createDocumentShare(session: ApiSession, projectId: string, documentId: string, expiresInDays = 7) {
return apiRequest<DocumentShareDTO>(`/api/v1/projects/${projectId}/documents/${documentId}/shares`, {
method: 'POST',
token: session.token,
body: { expiresInDays },
})
}
export async function fetchDocumentBlob(session: ApiSession, projectId: string, documentId: string, download = false) {
const suffix = download ? 'download' : 'content'
const response = await fetch(`${getApiBaseUrl()}/api/v1/projects/${projectId}/documents/${documentId}/${suffix}`, {
headers: { Authorization: `Bearer ${session.token}` },
})
if (!response.ok) throw await responseError(response)
return response.blob()
}
export function fetchLegacySheetPreview(session: ApiSession, projectId: string, documentId: string) {
return apiRequest<{ sheetName: string; rows: string[][]; truncated: boolean }>(
`/api/v1/projects/${projectId}/documents/${documentId}/sheet-preview`,
{ token: session.token },
)
}
export async function exportDocument(session: ApiSession, projectId: string, documentId: string, format: 'md' | 'pdf' | 'docx') {
const response = await fetch(
`${getApiBaseUrl()}/api/v1/projects/${projectId}/documents/${documentId}/export?format=${format}`,
{ headers: { Authorization: `Bearer ${session.token}` } },
)
if (!response.ok) throw await responseError(response)
const blob = await response.blob()
const disposition = response.headers.get('content-disposition') ?? ''
downloadBlob(blob, dispositionName(disposition) || `document.${format}`)
}
export type UploadControl = {
promise: Promise<DocumentDTO>
cancel: () => void
}
export function uploadDocument(
session: ApiSession,
projectId: string,
file: File,
parentId: string,
onProgress: (progress: number) => void,
): UploadControl {
const xhr = new XMLHttpRequest()
const promise = new Promise<DocumentDTO>((resolve, reject) => {
xhr.open('POST', `${getApiBaseUrl()}/api/v1/projects/${projectId}/documents/uploads`)
xhr.setRequestHeader('Authorization', `Bearer ${session.token}`)
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) onProgress(Math.round((event.loaded / event.total) * 100))
}
xhr.onerror = () => reject(new ApiError(0, 'network_error', '上传失败,请检查网络后重试'))
xhr.onabort = () => reject(new ApiError(0, 'upload_cancelled', '上传已取消'))
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
resolve(JSON.parse(xhr.responseText) as DocumentDTO)
} catch {
reject(new ApiError(xhr.status, 'invalid_response', '服务器返回了无效的上传结果'))
}
return
}
try {
const payload = JSON.parse(xhr.responseText) as { error?: { code?: string; message?: string } }
reject(new ApiError(xhr.status, payload.error?.code ?? 'upload_failed', payload.error?.message ?? '上传失败'))
} catch {
reject(new ApiError(xhr.status, 'upload_failed', '上传失败'))
}
}
const body = new FormData()
body.append('file', file)
if (parentId) body.append('parentId', parentId)
xhr.send(body)
})
return { promise, cancel: () => xhr.abort() }
}
export function downloadBlob(blob: Blob, name: string) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = name
anchor.click()
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
async function responseError(response: Response) {
try {
const payload = await response.json() as { error?: { code?: string; message?: string } }
return new ApiError(response.status, payload.error?.code ?? 'request_failed', payload.error?.message ?? '请求失败')
} catch {
return new ApiError(response.status, 'request_failed', '请求失败')
}
}
function dispositionName(value: string) {
const encoded = value.match(/filename\*=UTF-8''([^;]+)/i)?.[1]
return encoded ? decodeURIComponent(encoded) : ''
}

View File

@@ -13,7 +13,7 @@ export type InboxItemDTO = {
export type InboxSuggestionDTO = {
id: string
kind: 'task' | 'note' | 'source'
kind: 'task' | 'document'
title: string
body: string
}

View File

@@ -5,10 +5,10 @@ import type {
WorkspaceChannelDTO,
WorkspaceCronPlanDTO,
WorkspaceInboxDTO,
WorkspaceNoteSourceDTO,
WorkspaceDocumentDTO,
WorkspaceTaskDTO,
} from './projects'
import type { AISession, ChannelKey, CronJob, InboxItem, NoteSource, Project, ProjectChannel, ProjectWorkspace, TaskItem } from '../pages/projects/project-types'
import type { AISession, ChannelKey, CronJob, DocumentSummary, InboxItem, Project, ProjectChannel, ProjectWorkspace, TaskItem } from '../pages/projects/project-types'
const projectColors = ['#165DFF', '#00B42A', '#722ED1', '#FF7D00', '#14C9C9', '#F53F3F']
@@ -34,7 +34,7 @@ export function mapWorkspace(payload: ProjectWorkspaceDTO, index = 0): ProjectWo
inbox: payload.inbox.map(mapInbox),
tasks: payload.tasks.map(mapTask),
aiSessions: payload.aiSessions.map(mapAISession),
notes: payload.notesSources.map(mapNoteSource),
documents: payload.documents.map(mapDocument),
cronJobs: payload.cronPlans.map(mapCronPlan),
}
}
@@ -75,8 +75,8 @@ function mapChannelKey(type: string, id = ''): ChannelKey {
return 'tasks'
case 'ai_sessions':
return 'ai'
case 'notes_sources':
return 'notes'
case 'documents':
return 'documents'
case 'cron':
return 'cron'
case 'overview':
@@ -96,7 +96,7 @@ function channelLabel(channel: WorkspaceChannelDTO) {
return '工作计划'
case 'ai_sessions':
return 'AI 会话'
case 'notes_sources':
case 'documents':
return '笔记资料'
case 'cron':
return '计划任务'
@@ -115,7 +115,7 @@ function channelIcon(channel: WorkspaceChannelDTO) {
return <IconList />
case 'ai_sessions':
return <IconRobot />
case 'notes_sources':
case 'documents':
return <IconFile />
case 'cron':
return <IconClockCircle />
@@ -194,14 +194,14 @@ function mapAISession(session: WorkspaceAISessionDTO): AISession {
}
}
function mapNoteSource(note: WorkspaceNoteSourceDTO): NoteSource {
function mapDocument(document: WorkspaceDocumentDTO): DocumentSummary {
return {
id: note.id,
title: note.title,
type: note.kind === 'note' ? '笔记' : note.source,
updated: formatBackendDate(note.updatedAt),
owner: note.source,
source: note.source,
id: document.id,
name: document.name,
kind: document.kind,
extension: document.extension,
mimeType: document.mimeType,
updated: formatBackendDate(document.updatedAt),
}
}

View File

@@ -70,14 +70,14 @@ export type WorkspaceAISessionDTO = {
references: string[]
}
export type WorkspaceNoteSourceDTO = {
export type WorkspaceDocumentDTO = {
id: string
projectId: string
kind: string
title: string
name: string
extension: string
mimeType: string
updatedAt: string
tag: string
source: string
}
export type WorkspaceCronPlanDTO = {
@@ -99,7 +99,7 @@ export type ProjectWorkspaceDTO = {
inbox: WorkspaceInboxDTO[]
tasks: WorkspaceTaskDTO[]
aiSessions: WorkspaceAISessionDTO[]
notesSources: WorkspaceNoteSourceDTO[]
documents: WorkspaceDocumentDTO[]
cronPlans: WorkspaceCronPlanDTO[]
}
@@ -119,16 +119,6 @@ export type TaskDTO = {
updatedAt: string
}
export type SourceDTO = {
id: string
projectId: string
kind: string
title: string
filePath: string
createdAt: string
updatedAt: string
}
export type CronPlanDTO = {
id: string
projectId: string
@@ -168,11 +158,6 @@ export type UpdateTaskInput = {
tag?: string
}
export type UploadSourceInput = {
title?: string
file: File
}
export type CreateCronPlanInput = {
title: string
schedule: string
@@ -228,17 +213,6 @@ export async function updateTask(session: ApiSession, projectId: string, taskId:
})
}
export async function uploadSource(session: ApiSession, projectId: string, input: UploadSourceInput) {
const body = new FormData()
body.append('file', input.file)
if (input.title) body.append('title', input.title)
return apiRequest<SourceDTO>(`/api/v1/projects/${projectId}/sources`, {
method: 'POST',
token: session.token,
body,
})
}
export async function createCronPlan(session: ApiSession, projectId: string, input: CreateCronPlanInput) {
return apiRequest<CronPlanDTO>(`/api/v1/projects/${projectId}/cron-plans`, {
method: 'POST',

View File

@@ -1,7 +1,7 @@
import { apiRequest, type ApiSession } from './client'
export type SearchResultDTO = {
type: 'project' | 'task' | 'note'
type: 'project' | 'task' | 'document'
id: string
projectId: string
title: string

View File

@@ -27,11 +27,11 @@ import {
fetchProjects,
updateProject,
updateTask,
uploadSource,
} from '../api/projects'
import type { DocumentOpenIntent } from '../api/documents'
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 { ProjectActionModals, type CronDraft, type ProjectActionModal, type ProjectDraft, type TaskDraft } from '../pages/projects/project-action-modals'
import { SearchResultPreview } from '../pages/projects/search-result-preview'
import type { ProjectSettingsUpdate } from '../pages/projects/project-sidebar'
import { ProjectPage } from '../pages/workspace-home'
@@ -54,6 +54,7 @@ function App() {
const [actionLoading, setActionLoading] = useState(false)
const [activeModal, setActiveModal] = useState<ProjectActionModal>(null)
const [searchResultPreview, setSearchResultPreview] = useState<SearchResultDTO | null>(null)
const [documentIntent, setDocumentIntent] = useState<DocumentOpenIntent | null>(null)
const workspaceSearch = useWorkbenchSearch(session)
const handleListAISessions = useCallback((projectId: string, signal?: AbortSignal) => {
@@ -119,6 +120,17 @@ function App() {
return { ...result, refreshFailed: true }
}
}
setDocumentIntent({
intentId: crypto.randomUUID(),
mode: 'open',
projectId,
documentId: result.documentId,
sourceType: 'dataset',
sourceId: itemId,
})
selectActiveProject(projectId)
setActiveView('project')
setActiveChannel('documents')
return result
}, [session, workspaces])
@@ -238,22 +250,6 @@ function App() {
}, '任务已创建')
}
function handleUploadSource(draft: SourceDraft) {
if (!draft.file) {
Message.warning('请选择文件')
return
}
const file = draft.file
void runAction(async () => {
await uploadSource(requireSession(), requireActiveProject(), {
title: draft.title.trim(),
file,
})
await refreshAfterAction()
setActiveChannel('notes')
}, '文件已上传')
}
function handleCreateCronPlan(draft: CronDraft) {
if (!draft.title.trim()) {
Message.warning('请输入计划名称')
@@ -375,7 +371,16 @@ function App() {
setActiveView('project')
setActiveChannel(target.channel)
setActiveTaskID(target.openTask ? result.id : null)
if (result.type === 'note') setSelectedItem(result.title)
if (result.type === 'document') {
setDocumentIntent({
intentId: crypto.randomUUID(),
mode: 'open',
projectId: result.projectId,
documentId: result.id,
sourceType: 'search',
sourceId: result.id,
})
}
}
return (
@@ -389,6 +394,11 @@ function App() {
<ProjectPage
activeView={activeView}
activeWorkspace={activeWorkspace}
session={session}
documentIntent={documentIntent}
onDocumentIntentConsumed={(intentId) => {
setDocumentIntent((current) => current?.intentId === intentId ? null : current)
}}
workspaces={workspaces}
activeChannel={activeChannel}
activeTaskID={activeTaskID}
@@ -411,7 +421,6 @@ function App() {
onToggleTheme={() => setTheme(dark ? 'light' : 'dark')}
onCreateProject={() => setActiveModal('project')}
onCreateTask={() => setActiveModal('task')}
onUploadSource={() => setActiveModal('source')}
onCreateCronPlan={() => setActiveModal('cron')}
onUpdateWorkspaceTask={handleUpdateWorkspaceTask}
onUpdateProject={handleUpdateProject}
@@ -446,7 +455,6 @@ function App() {
onClose={() => setActiveModal(null)}
onCreateProject={handleCreateProject}
onCreateTask={handleCreateTask}
onUploadSource={handleUploadSource}
onCreateCronPlan={handleCreateCronPlan}
tagOptions={activeTagOptions}
/>
@@ -459,13 +467,13 @@ function App() {
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 }
if (type === 'document') return { channel: 'documents', openTask: false }
return null
}
function isAuthorizedExternalPreview(result: SearchResultDTO) {
return (
(result.type === 'task' || result.type === 'note')
(result.type === 'task' || result.type === 'document')
&& typeof result.projectId === 'string'
&& result.projectId.trim() !== ''
&& typeof result.title === 'string'

View File

@@ -0,0 +1,78 @@
import { useEffect, useRef } from 'react'
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
import { markdown } from '@codemirror/lang-markdown'
import { EditorState } from '@codemirror/state'
import { EditorView, keymap, lineNumbers } from '@codemirror/view'
import { oneDark } from '@codemirror/theme-one-dark'
export function DocumentMarkdownEditor({
value,
dark,
onChange,
onSave,
}: {
value: string
dark: boolean
onChange: (value: string) => void
onSave: () => void
}) {
const hostRef = useRef<HTMLDivElement>(null)
const viewRef = useRef<EditorView | null>(null)
const onChangeRef = useRef(onChange)
const onSaveRef = useRef(onSave)
onChangeRef.current = onChange
onSaveRef.current = onSave
useEffect(() => {
if (!hostRef.current) return
const view = new EditorView({
parent: hostRef.current,
state: EditorState.create({
doc: value,
extensions: [
lineNumbers(),
history(),
markdown(),
keymap.of([
{
key: 'Mod-s',
preventDefault: true,
run: () => {
onSaveRef.current()
return true
},
},
...defaultKeymap,
...historyKeymap,
]),
EditorView.lineWrapping,
EditorView.updateListener.of((update) => {
if (update.docChanged) onChangeRef.current(update.state.doc.toString())
}),
EditorView.theme({
'&': { height: '100%', fontSize: '14px' },
'.cm-scroller': { fontFamily: 'JetBrains Mono, Cascadia Code, Consolas, monospace', lineHeight: '1.7' },
'.cm-content': { padding: '22px 26px 40px' },
'.cm-gutters': { borderRight: '1px solid var(--senlin-border)' },
}),
...(dark ? [oneDark] : []),
],
}),
})
viewRef.current = view
return () => {
view.destroy()
viewRef.current = null
}
// Recreate only when theme changes; external value updates are handled below.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dark])
useEffect(() => {
const view = viewRef.current
if (!view || view.state.doc.toString() === value) return
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: value } })
}, [value])
return <div className="document-codemirror" ref={hostRef} />
}

View File

@@ -4,7 +4,7 @@ import { Input, Modal, Select, Space, Switch, Typography } from '@arco-design/we
const { Text } = Typography
const { TextArea } = Input
export type ProjectActionModal = 'project' | 'task' | 'source' | 'cron' | null
export type ProjectActionModal = 'project' | 'task' | 'cron' | null
export type ProjectDraft = {
name: string
@@ -20,11 +20,6 @@ export type TaskDraft = {
tag: string
}
export type SourceDraft = {
title: string
file: File | null
}
export type CronDraft = {
title: string
schedule: string
@@ -38,7 +33,6 @@ export function ProjectActionModals({
onClose,
onCreateProject,
onCreateTask,
onUploadSource,
onCreateCronPlan,
tagOptions,
}: {
@@ -47,20 +41,17 @@ export function ProjectActionModals({
onClose: () => void
onCreateProject: (draft: ProjectDraft) => void
onCreateTask: (draft: TaskDraft) => void
onUploadSource: (draft: SourceDraft) => void
onCreateCronPlan: (draft: CronDraft) => void
tagOptions: string[]
}) {
const [project, setProject] = useState<ProjectDraft>({ name: '', identifier: '', icon: '', background: '', description: '' })
const [task, setTask] = useState<TaskDraft>({ title: '', description: '', tag: '' })
const [source, setSource] = useState<SourceDraft>({ title: '', file: null })
const [cron, setCron] = useState<CronDraft>({ title: '', schedule: '0 9 * * *', enabled: true, nextRunAt: '' })
useEffect(() => {
if (activeModal === null) {
setProject({ name: '', identifier: '', icon: '', background: '', description: '' })
setTask({ title: '', description: '', tag: '' })
setSource({ title: '', file: null })
setCron({ title: '', schedule: '0 9 * * *', enabled: true, nextRunAt: '' })
}
}, [activeModal])
@@ -132,30 +123,6 @@ export function ProjectActionModals({
</Space>
</Modal>
<Modal
className="action-modal"
title="上传文件"
visible={activeModal === 'source'}
confirmLoading={loading}
onCancel={onClose}
onOk={() => onUploadSource(source)}
>
<Space direction="vertical" size={12} className="action-form">
<label>
<Text></Text>
<Input placeholder="默认使用文件名" value={source.title} onChange={(title) => setSource((draft) => ({ ...draft, title }))} />
</label>
<label>
<Text></Text>
<input
className="native-file-input"
type="file"
onChange={(event) => setSource((draft) => ({ ...draft, file: event.target.files?.[0] ?? null }))}
/>
</label>
</Space>
</Modal>
<Modal
className="action-modal"
title="新建计划任务"

View File

@@ -2,23 +2,28 @@ 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 { ProjectDocuments } from './project-documents'
import { ProjectOverview } from './project-overview'
import type { ProjectTaskUpdate } from './project-task-edit-modal'
import { ProjectTasks } from './project-tasks'
import type { ChannelKey, InboxConfirmationOutcome, ProjectWorkspace } from './project-types'
import type { InboxSuggestionDTO } from '../../api/inbox'
import type { AIExpertDTO, AISessionDTO, CreateAISessionInput } from '../../api/ai'
import type { ApiSession } from '../../api/client'
import type { DocumentOpenIntent } from '../../api/documents'
export function ProjectChannelPage({
activeChannel,
activeWorkspace,
session,
theme,
documentIntent,
onDocumentIntentConsumed,
activeTaskID,
onSelectItem,
onOpenTask,
onCloseTask,
onCreateTask,
onUploadSource,
onCreateCronPlan,
onCreateProjectTag,
onUpdateTask,
@@ -30,12 +35,15 @@ export function ProjectChannelPage({
}: {
activeChannel: ChannelKey
activeWorkspace: ProjectWorkspace
session: ApiSession
theme: 'light' | 'dark'
documentIntent: DocumentOpenIntent | null
onDocumentIntentConsumed: (intentId: string) => void
activeTaskID: string | null
onSelectItem: (title: string) => void
onOpenTask: (taskID: string) => void
onCloseTask: () => void
onCreateTask: () => void
onUploadSource: () => void
onCreateCronPlan: () => void
onCreateProjectTag: (name: string) => void
onUpdateTask: (update: ProjectTaskUpdate) => void
@@ -52,8 +60,17 @@ export function ProjectChannelPage({
return <ProjectTasks activeWorkspace={activeWorkspace} activeTaskID={activeTaskID} onOpenTask={onOpenTask} onCloseTask={onCloseTask} onSelectItem={onSelectItem} onCreateTask={onCreateTask} onCreateProjectTag={onCreateProjectTag} onUpdateTask={onUpdateTask} />
case 'ai':
return <ProjectAi key={activeWorkspace.project.id} activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onListSessions={onListAISessions} onListExperts={onListAIExperts} onCreateSession={onCreateAISession} />
case 'notes':
return <ProjectNotes activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onUploadSource={onUploadSource} />
case 'documents':
return (
<ProjectDocuments
key={activeWorkspace.project.id}
session={session}
projectId={activeWorkspace.project.id}
theme={theme}
intent={documentIntent}
onIntentConsumed={onDocumentIntentConsumed}
/>
)
case 'cron':
return <ProjectCron activeWorkspace={activeWorkspace} onSelectItem={onSelectItem} onCreateCronPlan={onCreateCronPlan} />
case 'new-channel':

View File

@@ -0,0 +1,821 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Button,
Dropdown,
Empty,
Menu,
Message,
Progress,
Spin,
Tooltip,
} from '@arco-design/web-react'
import {
IconClose,
IconDelete,
IconDownload,
IconEdit,
IconEye,
IconFile,
IconFileAudio,
IconFileImage,
IconFilePdf,
IconFileVideo,
IconFolder,
IconFolderAdd,
IconLeft,
IconPlus,
IconRight,
IconSave,
IconShareAlt,
IconUpload,
} from '@arco-design/web-react/icon'
import ReactMarkdown from 'react-markdown'
import rehypeSanitize from 'rehype-sanitize'
import remarkGfm from 'remark-gfm'
import DOMPurify from 'dompurify'
import mammoth from 'mammoth'
import { readSheet as readXlsxSheet } from 'read-excel-file/browser'
import type { ApiSession } from '../../api/client'
import {
createDocumentFolder,
createDocumentShare,
createMarkdownDocument,
deleteDocument,
downloadBlob,
exportDocument,
fetchDocumentBlob,
fetchLegacySheetPreview,
getDocument,
listDocuments,
updateDocument,
uploadDocument,
type DocumentDTO,
type DocumentOpenIntent,
type UploadControl,
} from '../../api/documents'
import type { Theme } from './project-types'
import { DocumentMarkdownEditor } from './document-markdown-editor'
type SaveStatus = 'clean' | 'dirty' | 'saving' | 'saved' | 'error'
type DocumentTab = {
key: string
documentId?: string
parentId?: string
name: string
extension: string
mimeType: string
markdown: string
revision: number
status: SaveStatus
mode: 'edit' | 'preview'
}
type UploadItem = {
id: string
file: File
progress: number
status: 'queued' | 'uploading' | 'done' | 'error' | 'cancelled'
error?: string
}
export function ProjectDocuments({
session,
projectId,
theme,
intent,
onIntentConsumed,
}: {
session: ApiSession
projectId: string
theme: Theme
intent: DocumentOpenIntent | null
onIntentConsumed: (intentId: string) => void
}) {
const [documents, setDocuments] = useState<DocumentDTO[]>([])
const [tabs, setTabs] = useState<DocumentTab[]>([])
const [activeKey, setActiveKey] = useState('')
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const [loading, setLoading] = useState(true)
const [treeWidth, setTreeWidth] = useState(260)
const [treeCollapsed, setTreeCollapsed] = useState(false)
const [mobilePane, setMobilePane] = useState<'tree' | 'editor'>('tree')
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; document: DocumentDTO } | null>(null)
const [uploads, setUploads] = useState<UploadItem[]>([])
const tabsRef = useRef<DocumentTab[]>([])
const timersRef = useRef(new Map<string, number>())
const uploadControlsRef = useRef(new Map<string, UploadControl>())
const fileInputRef = useRef<HTMLInputElement>(null)
tabsRef.current = tabs
const activeTab = tabs.find((tab) => tab.key === activeKey)
const rootDocuments = useMemo(() => buildTree(documents), [documents])
const refreshTree = useCallback(async () => {
const result = await listDocuments(session, projectId)
setDocuments(result)
return result
}, [projectId, session])
const openDocument = useCallback(async (document: DocumentDTO) => {
if (document.kind === 'folder') {
setExpanded((current) => toggleSet(current, document.id))
return
}
const existing = tabsRef.current.find((tab) => tab.documentId === document.id)
if (existing) {
setActiveKey(existing.key)
setMobilePane('editor')
return
}
try {
const detail = await getDocument(session, projectId, document.id)
const next = tabFromDocument(detail)
setTabs((current) => [...current, next])
setActiveKey(next.key)
setMobilePane('editor')
} catch (error) {
Message.error(error instanceof Error ? error.message : '文档打开失败')
}
}, [projectId, session])
const createDraft = useCallback((name = '新建文件1.md', markdown = '', parentId = '') => {
const key = `draft:${crypto.randomUUID()}`
const tab: DocumentTab = {
key,
parentId,
name,
extension: '.md',
mimeType: 'text/markdown; charset=utf-8',
markdown,
revision: 0,
status: markdown ? 'dirty' : 'clean',
mode: 'edit',
}
setTabs((current) => [...current, tab])
setActiveKey(key)
setMobilePane('editor')
return key
}, [])
useEffect(() => {
let cancelled = false
const timers = timersRef.current
setTabs([])
setActiveKey('')
setLoading(true)
void refreshTree().then(async (items) => {
if (cancelled) return
const stored = readStoredTabs(projectId)
const restored: DocumentTab[] = []
for (const id of stored) {
const item = items.find((document) => document.id === id && document.kind === 'file')
if (!item) continue
try {
const detail = await getDocument(session, projectId, id)
if (!cancelled) restored.push(tabFromDocument(detail))
} catch {
// Missing tabs are discarded.
}
}
if (cancelled) return
if (restored.length > 0) {
setTabs(restored)
setActiveKey(restored[0].key)
setMobilePane('editor')
} else {
createDraft()
}
}).catch((error) => {
if (!cancelled) Message.error(error instanceof Error ? error.message : '文档树加载失败')
}).finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
for (const timer of timers.values()) window.clearTimeout(timer)
timers.clear()
}
}, [createDraft, projectId, refreshTree, session])
useEffect(() => {
const ids = tabs.flatMap((tab) => tab.documentId ? [tab.documentId] : [])
localStorage.setItem(storageKey(projectId), JSON.stringify(ids))
}, [projectId, tabs])
useEffect(() => {
if (!intent || intent.projectId !== projectId) return
if (intent.mode === 'open' && intent.documentId) {
const document = documents.find((item) => item.id === intent.documentId)
if (document) void openDocument(document)
} else {
createDraft(intent.suggestedName || '新建文件1.md', intent.initialMarkdown || '', intent.targetDirectoryId || '')
}
onIntentConsumed(intent.intentId)
}, [createDraft, documents, intent, onIntentConsumed, openDocument, projectId])
const persistTab = useCallback(async (key: string, forcedMarkdown?: string) => {
const tab = tabsRef.current.find((item) => item.key === key)
if (!tab || tab.extension !== '.md' || tab.status === 'saving') return
const markdown = forcedMarkdown ?? tab.markdown
setTabs((current) => patchTab(current, key, { status: 'saving' }))
try {
if (!tab.documentId) {
const created = await createMarkdownDocument(session, projectId, { name: tab.name, parentId: tab.parentId, markdown })
setDocuments((current) => [...current, created])
setTabs((current) => patchTab(current, key, {
documentId: created.id,
key: created.id,
name: created.name,
revision: created.revision,
status: 'saved',
}))
setActiveKey((current) => current === key ? created.id : current)
} else {
const updated = await updateDocument(session, projectId, tab.documentId, {
markdown,
revision: tab.revision,
})
setDocuments((current) => current.map((item) => item.id === updated.id ? updated : item))
setTabs((current) => patchTab(current, key, {
markdown: updated.markdown ?? markdown,
revision: updated.revision,
status: 'saved',
}))
}
} catch (error) {
setTabs((current) => patchTab(current, key, { status: 'error' }))
Message.error(error instanceof Error ? error.message : '自动保存失败,本地草稿已保留')
}
}, [projectId, session])
function changeMarkdown(key: string, markdown: string) {
setTabs((current) => patchTab(current, key, { markdown, status: 'dirty' }))
const previous = timersRef.current.get(key)
if (previous) window.clearTimeout(previous)
timersRef.current.set(key, window.setTimeout(() => {
timersRef.current.delete(key)
void persistTab(key, markdown)
}, 800))
}
async function addFolder(parentId = '') {
const name = window.prompt('目录名称')
if (!name?.trim()) return
try {
const created = await createDocumentFolder(session, projectId, name.trim(), parentId)
setDocuments((current) => [...current, created])
} catch (error) {
Message.error(error instanceof Error ? error.message : '目录创建失败')
}
}
async function renameNode(document: DocumentDTO) {
const name = window.prompt('重命名', document.name)
if (!name?.trim() || name.trim() === document.name) return
try {
const updated = await updateDocument(session, projectId, document.id, { name: name.trim() })
setDocuments((current) => current.map((item) => item.id === updated.id ? updated : item))
setTabs((current) => current.map((tab) => tab.documentId === updated.id ? { ...tab, name: updated.name } : tab))
} catch (error) {
Message.error(error instanceof Error ? error.message : '重命名失败')
}
}
async function removeNode(document: DocumentDTO) {
if (!window.confirm(`确定删除“${document.name}${document.kind === 'folder' ? '及其全部内容' : ''}吗?`)) return
try {
await deleteDocument(session, projectId, document.id)
const removed = descendantIds(documents, document.id)
setDocuments((current) => current.filter((item) => !removed.has(item.id)))
setTabs((current) => current.filter((tab) => !tab.documentId || !removed.has(tab.documentId)))
if (activeTab?.documentId && removed.has(activeTab.documentId)) setActiveKey('')
} catch (error) {
Message.error(error instanceof Error ? error.message : '删除失败')
}
}
async function moveNode(documentId: string, parentId: string) {
const document = documents.find((item) => item.id === documentId)
if (!document || document.parentId === (parentId || null)) return
try {
const updated = await updateDocument(session, projectId, documentId, { parentId })
setDocuments((current) => current.map((item) => item.id === updated.id ? updated : item))
} catch (error) {
Message.error(error instanceof Error ? error.message : '移动失败')
}
}
function enqueueFiles(files: FileList | File[]) {
const accepted = Array.from(files).filter((file) => {
if (file.size <= 50 * 1024 * 1024) return true
Message.error(`${file.name} 超过 50 MB未加入上传队列`)
return false
})
setUploads((current) => [
...current,
...accepted.map((file) => ({
id: crypto.randomUUID(),
file,
progress: 0,
status: 'queued' as const,
})),
])
}
useEffect(() => {
const running = uploads.filter((item) => item.status === 'uploading').length
const queued = uploads.filter((item) => item.status === 'queued').slice(0, Math.max(0, 3 - running))
for (const item of queued) {
setUploads((current) => current.map((candidate) => candidate.id === item.id ? { ...candidate, status: 'uploading' } : candidate))
const control = uploadDocument(session, projectId, item.file, '', (progress) => {
setUploads((current) => current.map((candidate) => candidate.id === item.id ? { ...candidate, progress } : candidate))
})
uploadControlsRef.current.set(item.id, control)
void control.promise.then((document) => {
setDocuments((current) => [...current, document])
setUploads((current) => current.map((candidate) => candidate.id === item.id ? { ...candidate, progress: 100, status: 'done' } : candidate))
void openDocument(document)
}).catch((error) => {
setUploads((current) => current.map((candidate) => candidate.id === item.id
? { ...candidate, status: 'error', error: error instanceof Error ? error.message : '上传失败' }
: candidate))
}).finally(() => {
uploadControlsRef.current.delete(item.id)
window.setTimeout(() => {
setUploads((current) => current.filter((candidate) => candidate.id !== item.id || candidate.status !== 'done'))
}, 1800)
})
}
}, [openDocument, projectId, session, uploads])
function closeTab(tab: DocumentTab) {
if ((tab.status === 'dirty' || tab.status === 'error') && !window.confirm(`${tab.name}”尚未成功保存,仍要关闭吗?`)) return
const index = tabs.findIndex((item) => item.key === tab.key)
const next = tabs.filter((item) => item.key !== tab.key)
if (tab.key !== activeKey) {
setTabs(next)
return
}
const replacement = next[Math.max(0, index - 1)]
if (replacement) {
setTabs(next)
setActiveKey(replacement.key)
return
}
const key = `draft:${crypto.randomUUID()}`
setTabs([{
key,
name: '新建文件1.md',
extension: '.md',
mimeType: 'text/markdown; charset=utf-8',
markdown: '',
revision: 0,
status: 'clean',
mode: 'edit',
}])
setActiveKey(key)
}
async function downloadActive() {
if (!activeTab?.documentId) return
try {
const blob = await fetchDocumentBlob(session, projectId, activeTab.documentId, true)
downloadBlob(blob, activeTab.name)
} catch (error) {
Message.error(error instanceof Error ? error.message : '下载失败')
}
}
async function shareActive(days: 1 | 7 | 30) {
if (!activeTab?.documentId) {
Message.warning('请先保存文档')
return
}
try {
const share = await createDocumentShare(session, projectId, activeTab.documentId, days)
if (share.url) {
try {
await navigator.clipboard.writeText(share.url)
Message.success(`${days} 天有效的预览链接已复制`)
} catch {
window.prompt('复制分享链接', share.url)
}
}
} catch (error) {
Message.error(error instanceof Error ? error.message : '分享链接创建失败')
}
}
function beginResize(event: React.MouseEvent) {
const startX = event.clientX
const startWidth = treeWidth
function move(moveEvent: MouseEvent) {
setTreeWidth(Math.min(420, Math.max(210, startWidth + moveEvent.clientX - startX)))
}
function finish() {
window.removeEventListener('mousemove', move)
window.removeEventListener('mouseup', finish)
}
window.addEventListener('mousemove', move)
window.addEventListener('mouseup', finish)
}
const uploadProgress = uploads.length === 0
? 0
: Math.round(uploads.reduce((sum, item) => sum + item.progress, 0) / uploads.length)
return (
<section
className={`documents-canvas mobile-${mobilePane}`}
onClick={() => setContextMenu(null)}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault()
if (event.dataTransfer.files.length > 0) enqueueFiles(event.dataTransfer.files)
}}
>
{!treeCollapsed && (
<aside className="documents-tree-pane" style={{ width: treeWidth }}>
<div className="documents-tree-tools">
<Tooltip content="新建目录"><Button type="text" icon={<IconFolderAdd />} onClick={() => void addFolder()} /></Tooltip>
<Tooltip content="新建 Markdown"><Button type="text" icon={<IconPlus />} onClick={() => createDraft()} /></Tooltip>
<span className="documents-tree-spacer" />
<Tooltip content="上传文件"><Button type="text" icon={<IconUpload />} onClick={() => fileInputRef.current?.click()} /></Tooltip>
<Tooltip content="折叠文件树"><Button type="text" icon={<IconLeft />} onClick={() => setTreeCollapsed(true)} /></Tooltip>
<input ref={fileInputRef} hidden multiple type="file" onChange={(event) => event.target.files && enqueueFiles(event.target.files)} />
</div>
<div
className="documents-tree"
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
const id = event.dataTransfer.getData('application/x-senlin-document')
if (id) {
event.stopPropagation()
void moveNode(id, '')
}
}}
>
{loading ? <Spin /> : rootDocuments.length === 0 ? <Empty description="暂无文件" /> : rootDocuments.map((node) => (
<TreeNode
key={node.document.id}
node={node}
depth={0}
expanded={expanded}
activeDocumentId={activeTab?.documentId}
onToggle={(id) => setExpanded((current) => toggleSet(current, id))}
onOpen={(document) => void openDocument(document)}
onMove={(id, parent) => void moveNode(id, parent)}
onContextMenu={(event, document) => {
event.preventDefault()
setContextMenu({ x: event.clientX, y: event.clientY, document })
}}
/>
))}
</div>
{uploads.length > 0 && (
<div className="document-upload-summary">
<div><span> {uploads.filter((item) => item.status === 'done').length}/{uploads.length}</span><span>{uploadProgress}%</span></div>
<Progress percent={uploadProgress} showText={false} size="small" />
{uploads.filter((item) => item.status === 'uploading' || item.status === 'error').map((item) => (
<div className="document-upload-row" key={item.id}>
<span title={item.error}>{item.file.name}</span>
<span>{item.status === 'error' ? '失败' : `${item.progress}%`}</span>
{item.status === 'uploading' ? (
<button onClick={() => uploadControlsRef.current.get(item.id)?.cancel()}></button>
) : (
<button onClick={() => setUploads((current) => current.map((candidate) => candidate.id === item.id
? { ...candidate, progress: 0, status: 'queued', error: undefined }
: candidate))}></button>
)}
</div>
))}
</div>
)}
</aside>
)}
{!treeCollapsed && <div className="documents-resizer" onMouseDown={beginResize} />}
<main className="documents-editor-pane">
<div className="documents-tabs-bar">
{treeCollapsed && <Button type="text" icon={<IconRight />} onClick={() => setTreeCollapsed(false)} />}
<div className="documents-tabs">
{tabs.map((tab) => (
<button className={tab.key === activeKey ? 'document-tab active' : 'document-tab'} key={tab.key} onClick={() => setActiveKey(tab.key)}>
{fileIcon(tab.extension, tab.mimeType)}
<span>{tab.name}</span>
<i className={`save-dot ${tab.status}`} title={saveLabel(tab.status)} />
<IconClose onClick={(event) => { event.stopPropagation(); closeTab(tab) }} />
</button>
))}
</div>
{activeTab && (
<div className="documents-tab-actions">
{activeTab.extension === '.md' && (
<Tooltip content={activeTab.mode === 'edit' ? '预览' : '编辑'}>
<Button
type="text"
icon={activeTab.mode === 'edit' ? <IconEye /> : <IconEdit />}
onClick={() => setTabs((current) => patchTab(current, activeTab.key, { mode: activeTab.mode === 'edit' ? 'preview' : 'edit' }))}
/>
</Tooltip>
)}
{activeTab.extension === '.md' && (
<Tooltip content="保存"><Button type="text" icon={<IconSave />} onClick={() => void persistTab(activeTab.key)} /></Tooltip>
)}
{activeTab.documentId && activeTab.extension === '.md' ? (
<Dropdown
trigger="click"
droplist={<Menu onClickMenuItem={(format: string) => void exportDocument(session, projectId, activeTab.documentId!, format as 'md' | 'pdf' | 'docx')}>
<Menu.Item key="md"> Markdown</Menu.Item>
<Menu.Item key="pdf"> PDF</Menu.Item>
<Menu.Item key="docx"> Word</Menu.Item>
</Menu>}
>
<Button type="text" icon={<IconDownload />} />
</Dropdown>
) : activeTab.documentId ? (
<Tooltip content="下载原文件"><Button type="text" icon={<IconDownload />} onClick={() => void downloadActive()} /></Tooltip>
) : null}
<Dropdown
trigger="click"
droplist={<Menu onClickMenuItem={(days) => void shareActive(Number(days) as 1 | 7 | 30)}>
<Menu.Item key="1"> 1 </Menu.Item>
<Menu.Item key="7"> 7 </Menu.Item>
<Menu.Item key="30"> 30 </Menu.Item>
</Menu>}
>
<Tooltip content="分享预览"><Button type="text" icon={<IconShareAlt />} /></Tooltip>
</Dropdown>
</div>
)}
</div>
<div className="documents-mobile-switch">
<Button icon={<IconLeft />} onClick={() => setMobilePane('tree')}></Button>
</div>
<div className="document-editor-body">
{!activeTab ? <Empty description="没有打开的文件" /> : activeTab.extension === '.md' && activeTab.mode === 'edit' ? (
<DocumentMarkdownEditor
value={activeTab.markdown}
dark={theme === 'dark'}
onChange={(value) => changeMarkdown(activeTab.key, value)}
onSave={() => void persistTab(activeTab.key)}
/>
) : activeTab.extension === '.md' ? (
<MarkdownPreview markdown={activeTab.markdown} />
) : activeTab.documentId ? (
<SmartPreview session={session} projectId={projectId} tab={activeTab} />
) : null}
</div>
</main>
{contextMenu && (
<div className="document-context-menu" style={{ left: contextMenu.x, top: contextMenu.y }}>
{contextMenu.document.kind === 'folder' && (
<>
<button onClick={() => void addFolder(contextMenu.document.id)}><IconFolderAdd /></button>
<button onClick={() => createDraft('新建文件1.md', '', contextMenu.document.id)}><IconPlus /> Markdown</button>
</>
)}
<button onClick={() => void renameNode(contextMenu.document)}><IconEdit /></button>
<button className="danger" onClick={() => void removeNode(contextMenu.document)}><IconDelete /></button>
</div>
)}
</section>
)
}
type TreeEntry = { document: DocumentDTO; children: TreeEntry[] }
function TreeNode({
node,
depth,
expanded,
activeDocumentId,
onToggle,
onOpen,
onMove,
onContextMenu,
}: {
node: TreeEntry
depth: number
expanded: Set<string>
activeDocumentId?: string
onToggle: (id: string) => void
onOpen: (document: DocumentDTO) => void
onMove: (documentId: string, parentId: string) => void
onContextMenu: (event: React.MouseEvent, document: DocumentDTO) => void
}) {
const { document } = node
const open = expanded.has(document.id)
return (
<>
<button
className={document.id === activeDocumentId ? 'document-tree-row active' : 'document-tree-row'}
style={{ paddingLeft: 8 + depth * 16 }}
draggable
onDragStart={(event) => event.dataTransfer.setData('application/x-senlin-document', document.id)}
onDragOver={(event) => { if (document.kind === 'folder') event.preventDefault() }}
onDrop={(event) => {
if (document.kind !== 'folder') return
event.preventDefault()
event.stopPropagation()
const id = event.dataTransfer.getData('application/x-senlin-document')
if (id) onMove(id, document.id)
}}
onContextMenu={(event) => onContextMenu(event, document)}
onClick={() => document.kind === 'folder' ? onToggle(document.id) : onOpen(document)}
onDoubleClick={() => onOpen(document)}
>
{document.kind === 'folder' ? <span className="tree-chevron">{open ? '⌄' : ''}</span> : <span className="tree-chevron" />}
{document.kind === 'folder' ? <IconFolder /> : fileIcon(document.extension, document.mimeType)}
<span>{document.name}</span>
</button>
{document.kind === 'folder' && open && node.children.map((child) => (
<TreeNode
key={child.document.id}
node={child}
depth={depth + 1}
expanded={expanded}
activeDocumentId={activeDocumentId}
onToggle={onToggle}
onOpen={onOpen}
onMove={onMove}
onContextMenu={onContextMenu}
/>
))}
</>
)
}
function MarkdownPreview({ markdown }: { markdown: string }) {
return (
<article className="document-markdown-preview">
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSanitize]}>{markdown}</ReactMarkdown>
</article>
)
}
function SmartPreview({ session, projectId, tab }: { session: ApiSession; projectId: string; tab: DocumentTab }) {
const [url, setUrl] = useState('')
const [text, setText] = useState('')
const [html, setHtml] = useState('')
const [rows, setRows] = useState<unknown[][]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
let active = true
let objectURL = ''
setLoading(true)
setError('')
const extension = tab.extension.toLowerCase()
if (extension === '.xls') {
void fetchLegacySheetPreview(session, projectId, tab.documentId!).then((preview) => {
if (active) setRows(preview.rows)
}).catch((reason) => {
if (active) setError(reason instanceof Error ? reason.message : '预览加载失败')
}).finally(() => {
if (active) setLoading(false)
})
return () => {
active = false
}
}
void fetchDocumentBlob(session, projectId, tab.documentId!).then(async (blob) => {
if (!active) return
if (isText(extension, tab.mimeType)) {
setText(await blob.text())
} else if (extension === '.docx') {
const result = await mammoth.convertToHtml({ arrayBuffer: await blob.arrayBuffer() })
if (active) setHtml(DOMPurify.sanitize(result.value))
} else if (extension === '.xlsx') {
const parsed = await readXlsxSheet(blob)
if (active) setRows(parsed as unknown[][])
} else {
const previewBlob = extension === '.svg'
? new Blob([DOMPurify.sanitize(await blob.text(), { USE_PROFILES: { svg: true, svgFilters: true } })], { type: 'image/svg+xml' })
: blob
objectURL = URL.createObjectURL(previewBlob)
setUrl(objectURL)
}
}).catch((reason) => {
if (active) setError(reason instanceof Error ? reason.message : '预览加载失败')
}).finally(() => {
if (active) setLoading(false)
})
return () => {
active = false
if (objectURL) URL.revokeObjectURL(objectURL)
}
}, [projectId, session, tab.documentId, tab.extension, tab.mimeType])
if (loading) return <Spin />
if (error) return <Empty description={error} />
if (tab.mimeType.startsWith('image/')) return <div className="document-media-preview"><img src={url} alt={tab.name} /></div>
if (tab.mimeType.startsWith('audio/')) return <div className="document-media-preview"><audio src={url} controls /></div>
if (tab.mimeType.startsWith('video/')) return <div className="document-media-preview"><video src={url} controls /></div>
if (tab.extension === '.pdf') return <iframe className="document-pdf-preview" src={url} title={tab.name} />
if (tab.extension === '.docx') return <article className="document-office-preview" dangerouslySetInnerHTML={{ __html: html }} />
if (tab.extension === '.xlsx' || tab.extension === '.xls') return (
<div className="document-sheet-preview"><table><tbody>{rows.map((row, rowIndex) => (
<tr key={rowIndex}>{row.map((cell, cellIndex) => <td key={cellIndex}>{String(cell ?? '')}</td>)}</tr>
))}</tbody></table></div>
)
if (text) return <pre className="document-text-preview">{text}</pre>
return <Empty description="此格式暂不支持在线预览,可使用右上角下载" />
}
function buildTree(documents: DocumentDTO[]) {
const entries = new Map<string, TreeEntry>()
for (const document of documents) entries.set(document.id, { document, children: [] })
const roots: TreeEntry[] = []
for (const entry of entries.values()) {
const parent = entry.document.parentId ? entries.get(entry.document.parentId) : null
if (parent) parent.children.push(entry)
else roots.push(entry)
}
const sort = (items: TreeEntry[]) => {
items.sort((left, right) => {
if (left.document.kind !== right.document.kind) return left.document.kind === 'folder' ? -1 : 1
return left.document.name.localeCompare(right.document.name, 'zh-CN', { numeric: true, sensitivity: 'base' })
})
items.forEach((item) => sort(item.children))
}
sort(roots)
return roots
}
function tabFromDocument(document: DocumentDTO): DocumentTab {
return {
key: document.id,
documentId: document.id,
parentId: document.parentId ?? '',
name: document.name,
extension: document.extension.toLowerCase(),
mimeType: document.mimeType,
markdown: document.markdown ?? '',
revision: document.revision,
status: 'clean',
mode: document.extension.toLowerCase() === '.md' ? 'edit' : 'preview',
}
}
function patchTab(tabs: DocumentTab[], key: string, patch: Partial<DocumentTab>) {
return tabs.map((tab) => tab.key === key ? { ...tab, ...patch } : tab)
}
function toggleSet(current: Set<string>, value: string) {
const next = new Set(current)
if (next.has(value)) next.delete(value)
else next.add(value)
return next
}
function descendantIds(documents: DocumentDTO[], root: string) {
const ids = new Set([root])
let changed = true
while (changed) {
changed = false
for (const document of documents) {
if (document.parentId && ids.has(document.parentId) && !ids.has(document.id)) {
ids.add(document.id)
changed = true
}
}
}
return ids
}
function storageKey(projectId: string) {
return `senlin:documents:tabs:${projectId}`
}
function readStoredTabs(projectId: string): string[] {
try {
const value: unknown = JSON.parse(localStorage.getItem(storageKey(projectId)) ?? '[]')
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
} catch {
return []
}
}
function saveLabel(status: SaveStatus) {
return { clean: '已保存', dirty: '未保存', saving: '保存中', saved: '已保存', error: '保存失败' }[status]
}
function fileIcon(extension: string, mimeType: string) {
if (extension === '.pdf') return <IconFilePdf />
if (mimeType.startsWith('image/')) return <IconFileImage />
if (mimeType.startsWith('audio/')) return <IconFileAudio />
if (mimeType.startsWith('video/')) return <IconFileVideo />
return <IconFile />
}
function isText(extension: string, mimeType: string) {
return mimeType.startsWith('text/') || [
'.txt', '.json', '.yaml', '.yml', '.xml', '.csv', '.ts', '.tsx', '.js', '.jsx', '.go', '.py', '.css', '.html',
].includes(extension)
}

View File

@@ -1,7 +1,7 @@
import { IconFile, IconFileImage, IconFilePdf, IconFolder } from '@arco-design/web-react/icon'
import type { NoteSource } from './project-types'
import type { DocumentSummary } from './project-types'
export type ProjectFileItem = NoteSource | {
export type ProjectFileItem = DocumentSummary | {
id: string
title: string
type: string
@@ -23,11 +23,12 @@ export function ProjectFileGrid({
<div className={compact ? 'project-file-grid compact' : 'project-file-grid'}>
{items.map((item) => {
const fileKind = inferFileKind(item)
const name = 'name' in item ? item.name : item.title
return (
<button className="project-file-tile" key={item.id} onClick={() => onSelectItem(item.title)}>
<button className="project-file-tile" key={item.id} onClick={() => onSelectItem(name)}>
<span className={`project-file-icon ${fileKind.kind}`}>{fileIcon(fileKind.kind)}</span>
<span className="project-file-copy">
<span className="project-file-name">{item.title}</span>
<span className="project-file-name">{name}</span>
<span className="project-file-type">{fileKind.label}</span>
<span className="project-file-meta">{fileKind.meta}</span>
</span>
@@ -39,6 +40,15 @@ export function ProjectFileGrid({
}
function inferFileKind(item: ProjectFileItem) {
if ('name' in item) {
if (item.kind === 'folder') return { kind: 'folder', label: '文件夹', meta: item.updated }
const value = `${item.name} ${item.extension} ${item.mimeType}`.toLowerCase()
if (item.extension === '.md') return { kind: 'document', label: 'Markdown 文档', meta: item.updated }
if (item.extension === '.pdf') return { kind: 'pdf', label: 'PDF 文档', meta: item.updated }
if (item.mimeType.startsWith('image/')) return { kind: 'image', label: '图片文件', meta: item.updated }
if (value.includes('sheet') || value.includes('excel')) return { kind: 'sheet', label: '表格文件', meta: item.updated }
return { kind: 'file', label: item.extension || '文件', meta: item.updated }
}
const value = `${item.title} ${item.type} ${item.source ?? ''}`.toLowerCase()
if (item.type === 'folder' || item.source === 'folder') return { kind: 'folder', label: '文件夹', meta: item.updated }
if (value.includes('pdf')) return { kind: 'pdf', label: 'PDF 文档', meta: item.updated }

View File

@@ -238,12 +238,10 @@ function isInboxItemProcessed(item: InboxItem, locallyConfirmedItemIds: string[]
function suggestionLabel(kind: InboxSuggestionDTO['kind']) {
if (kind === 'task') return '任务'
if (kind === 'note') return '笔记'
return '资料'
return 'Markdown 文档'
}
function suggestionColor(kind: InboxSuggestionDTO['kind']) {
if (kind === 'task') return 'arcoblue'
if (kind === 'note') return 'green'
return 'purple'
return 'green'
}

View File

@@ -1,33 +0,0 @@
import { Button, Card, Space, Typography } from '@arco-design/web-react'
import { IconPlus, IconSearch } from '@arco-design/web-react/icon'
import { ProjectFileGrid } from './project-file-grid'
import type { ProjectWorkspace } from './project-types'
const { Title, Text } = Typography
export function ProjectNotes({ activeWorkspace, onSelectItem, onUploadSource }: { activeWorkspace: ProjectWorkspace; onSelectItem: (title: string) => void; onUploadSource: () => void }) {
const { notes, project } = activeWorkspace
return (
<div className="project-channel-page project-notes-page overview-page">
<div className="overview-head">
<div>
<Title heading={4}></Title>
<Text type="secondary">{project.name} 线</Text>
</div>
<Space>
<Button icon={<IconSearch />}></Button>
<Button type="primary" icon={<IconPlus />} onClick={onUploadSource}>/</Button>
</Space>
</div>
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}></Title>
<Button type="text" size="mini"></Button>
</div>
<ProjectFileGrid items={notes} onSelectItem={onSelectItem} />
</Card>
</div>
)
}

View File

@@ -18,7 +18,7 @@ export function ProjectOverview({
onSelectItem: (title: string) => void
onUpdateTask: (update: ProjectTaskUpdate) => void
}) {
const { project, tasks, notes, cronJobs } = activeWorkspace
const { project, tasks, documents, cronJobs } = activeWorkspace
const runningTasks = useMemo(() => tasks.filter((task) => !task.completed), [tasks])
const completedTasks = useMemo(() => tasks.filter((task) => task.completed), [tasks])
const [editingTask, setEditingTask] = useState<TaskItem | null>(null)
@@ -26,9 +26,9 @@ export function ProjectOverview({
const metrics = useMemo(() => [
{ title: '进行中的任务', value: runningTasks.length, icon: <IconCheckCircleFill />, color: 'green' },
{ title: '完成的任务', value: completedTasks.length, icon: <IconCheckCircleFill />, color: 'arcoblue' },
{ title: '笔记资料总数', value: notes.length, icon: <IconFile />, color: 'cyan' },
{ title: '笔记资料总数', value: documents.length, icon: <IconFile />, color: 'cyan' },
{ title: '计划任务', value: cronJobs.length, icon: <IconClockCircle />, color: 'orange' },
], [completedTasks.length, cronJobs.length, notes.length, runningTasks.length])
], [completedTasks.length, cronJobs.length, documents.length, runningTasks.length])
return (
<div className="overview-page project-overview-page">
@@ -65,7 +65,7 @@ export function ProjectOverview({
<TaskCardSection title={`进行中的任务(${runningTasks.length}`} tasks={runningTasks} emptyText="当前没有进行中的任务" onEditTask={setEditingTask} />
<TaskCardSection title={`完成的任务(${completedTasks.length}`} tasks={completedTasks} emptyText="当前没有完成的任务" onSelectItem={onSelectItem} />
<NoteSection notes={notes} onSelectItem={onSelectItem} />
<DocumentSection documents={documents} onSelectItem={onSelectItem} />
<ProjectTaskEditModal
task={editingTask}
workspace={activeWorkspace}
@@ -122,14 +122,14 @@ function TaskCardSection({
)
}
function NoteSection({ notes, onSelectItem }: { notes: ProjectWorkspace['notes']; onSelectItem: (title: string) => void }) {
function DocumentSection({ documents, onSelectItem }: { documents: ProjectWorkspace['documents']; onSelectItem: (title: string) => void }) {
return (
<Card className="queue-section" bordered>
<div className="section-header">
<Title heading={6}>{notes.length}</Title>
<Title heading={6}>{documents.length}</Title>
<Button type="text" size="mini"></Button>
</div>
<ProjectFileGrid items={notes.slice(0, 8)} onSelectItem={onSelectItem} compact />
<ProjectFileGrid items={documents.slice(0, 8)} onSelectItem={onSelectItem} compact />
</Card>
)
}

View File

@@ -106,6 +106,6 @@ export function ProjectTopbar({
function searchTypeLabel(type: string) {
if (type === 'project') return '项目'
if (type === 'task') return '任务'
if (type === 'note') return '笔记'
if (type === 'document') return '笔记资料'
return '未知'
}

View File

@@ -3,7 +3,7 @@ import type { ReactElement } from 'react'
export type Screen = 'login' | 'workbench'
export type Theme = 'light' | 'dark'
export type WorkbenchView = 'workspace' | 'workspace-explore' | 'project'
export type ChannelKey = 'overview' | 'inbox' | 'tasks' | 'ai' | 'notes' | 'cron' | 'new-channel' | `custom:${string}`
export type ChannelKey = 'overview' | 'inbox' | 'tasks' | 'ai' | 'documents' | 'cron' | 'new-channel' | `custom:${string}`
export type Project = {
id: string
@@ -66,13 +66,13 @@ export type AISession = {
time: string
}
export type NoteSource = {
export type DocumentSummary = {
id: string
title: string
type: string
name: string
kind: string
extension: string
mimeType: string
updated: string
owner: string
source: string
}
export type CronJob = {
@@ -94,6 +94,6 @@ export type ProjectWorkspace = {
inbox: InboxItem[]
tasks: TaskItem[]
aiSessions: AISession[]
notes: NoteSource[]
documents: DocumentSummary[]
cronJobs: CronJob[]
}

View File

@@ -75,13 +75,13 @@ export function WorkspacePage({
setDraft((value) => ({ ...value, tag: '' }))
}, [draft.tag, tagOptions])
const aiSessions = workspaces.flatMap((workspace) => workspace.aiSessions)
const notes = workspaces.flatMap((workspace) => workspace.notes)
const documents = workspaces.flatMap((workspace) => workspace.documents)
const urgentProjects = projects.filter((project) => project.urgent).length
const workspaceMetrics = [
{ title: '项目总数', value: projects.length, delta: `${urgentProjects} 个高优先级`, icon: <IconDashboard />, color: 'arcoblue' },
{ title: '未完成计划', value: unfinishedTasks.length, delta: '跨项目聚合', icon: <IconCheckCircleFill />, color: 'green' },
{ title: 'AI 会话', value: aiSessions.length, delta: '项目内上下文', icon: <IconRobot />, color: 'purple' },
{ title: '知识资料', value: notes.length, delta: '笔记与资料合计', icon: <IconFile />, color: 'cyan' },
{ title: '知识资料', value: documents.length, delta: '笔记与资料合计', icon: <IconFile />, color: 'cyan' },
]
return (

View File

@@ -20,12 +20,17 @@ import type {
DatasetSyncResultDTO,
} from '../api/dataset'
import type { ChannelKey, InboxConfirmationOutcome, Project, ProjectWorkspace, Theme, WorkbenchView } from './projects/project-types'
import type { ApiSession } from '../api/client'
import type { DocumentOpenIntent } from '../api/documents'
const { Content } = Layout
export function ProjectPage({
activeView,
activeWorkspace,
session,
documentIntent,
onDocumentIntentConsumed,
workspaces,
activeChannel,
activeTaskID,
@@ -40,7 +45,6 @@ export function ProjectPage({
onToggleTheme,
onCreateProject,
onCreateTask,
onUploadSource,
onCreateCronPlan,
onUpdateWorkspaceTask,
onUpdateProject,
@@ -68,6 +72,9 @@ export function ProjectPage({
}: {
activeView: WorkbenchView
activeWorkspace?: ProjectWorkspace
session: ApiSession
documentIntent: DocumentOpenIntent | null
onDocumentIntentConsumed: (intentId: string) => void
workspaces: ProjectWorkspace[]
activeChannel: ChannelKey
activeTaskID: string | null
@@ -82,7 +89,6 @@ export function ProjectPage({
onToggleTheme: () => void
onCreateProject: () => void
onCreateTask: () => void
onUploadSource: () => void
onCreateCronPlan: () => void
onUpdateWorkspaceTask: (update: WorkspaceTaskUpdate) => void
onUpdateProject: (update: ProjectSettingsUpdate) => Promise<void>
@@ -179,12 +185,15 @@ export function ProjectPage({
<ProjectChannelPage
activeChannel={activeChannel}
activeWorkspace={activeWorkspace}
session={session}
theme={theme}
documentIntent={documentIntent}
onDocumentIntentConsumed={onDocumentIntentConsumed}
activeTaskID={activeTaskID}
onSelectItem={onSelectItem}
onOpenTask={(taskID) => onOpenTask(activeWorkspace.project, taskID)}
onCloseTask={onCloseTask}
onCreateTask={onCreateTask}
onUploadSource={onUploadSource}
onCreateCronPlan={onCreateCronPlan}
onCreateProjectTag={onCreateProjectTag}
onUpdateTask={updateActiveProjectTask}

View File

@@ -11,7 +11,7 @@ import (
"senlinai-agent/backend/internal/logic/ai"
"senlinai-agent/backend/internal/logic/auth"
"senlinai-agent/backend/internal/logic/dataset"
"senlinai-agent/backend/internal/logic/files"
"senlinai-agent/backend/internal/logic/documents"
"senlinai-agent/backend/internal/logic/inbox"
"senlinai-agent/backend/internal/logic/projects"
"senlinai-agent/backend/internal/logic/search"
@@ -35,13 +35,13 @@ func main() {
datasetHandler := dataset.NewHandler(datasetService)
datasetScheduler := crontab.NewDatasetScheduler(datasetService, log.Default())
projectService := projects.NewService()
fileService := files.NewService(cfg.StorageDir, models.DBService)
documentService := documents.NewService(models.DBService, cfg.StorageDir)
taskService := tasks.NewService(models.DBService)
projectHandler := projects.NewHandler(projectService)
tagHandler := projects.NewTagHandler(projectService)
cronHandler := projects.NewCronHandler(projectService)
taskHandler := tasks.NewHandler(taskService)
fileHandler := files.NewHandler(fileService, cfg.MaxUploadBytes)
documentHandler := documents.NewHandler(documentService, cfg.MaxUploadBytes)
inboxHandler := inbox.NewHandler(inbox.NewService(inbox.StaticAnalyzer{}))
searchHandler := search.NewHandler(search.NewService(models.DBService))
aiGateway := ai.NewGatewayWithSecret(cfg.SystemAIKey, cfg.AIKeyEncryptionSecret)
@@ -56,7 +56,7 @@ func main() {
tagHandler,
cronHandler,
taskHandler,
fileHandler,
documentHandler,
inboxHandler,
searchHandler,
aiHandler,

View File

@@ -2,7 +2,7 @@ env: development
port: "9150"
dsn: "postgres://postgres:postgres@localhost:5432/agent_dev?sslmode=disable"
storage_dir: "./data/files"
max_upload_bytes: 33554432
max_upload_bytes: 52428800
auth_secret: "development-auth-secret-change-me"
system_ai_key: ""
ai_key_encryption_secret: "development-ai-key-secret-change-me"

View File

@@ -3,15 +3,23 @@ module senlinai-agent/backend
go 1.26.1
require (
github.com/eidrisov/xlsReader v0.0.0-20231127092723-a4c25c0fa983
github.com/gin-gonic/gin v1.12.0
github.com/glebarez/sqlite v1.11.0
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.6.0
github.com/microcosm-cc/bluemonday v1.0.27
github.com/signintech/gopdf v0.37.0
github.com/stretchr/testify v1.11.1
github.com/yuin/goldmark v1.8.4
golang.org/x/crypto v0.48.0
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.2
)
require (
github.com/aymerick/douceur v0.2.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
@@ -26,10 +34,9 @@ require (
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.6.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
@@ -37,9 +44,12 @@ require (
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/metakeule/fmtdate v1.1.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/phpdave11/gofpdi v1.0.14-0.20211212211723-1f10f9844311 // indirect
github.com/pkg/errors v0.8.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
@@ -53,7 +63,6 @@ require (
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.5.0 // indirect

View File

@@ -1,3 +1,5 @@
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
@@ -11,6 +13,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/eidrisov/xlsReader v0.0.0-20231127092723-a4c25c0fa983 h1:+8V2eKDbP164L6cbG0GIqScp8ZNYEybSKVwXPFfnz80=
github.com/eidrisov/xlsReader v0.0.0-20231127092723-a4c25c0fa983/go.mod h1:EH17ZJR4xZm5bmZj0SDjBhNtGLx0rjEGd0wnAJon6x0=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
@@ -38,10 +42,10 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -68,6 +72,10 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/metakeule/fmtdate v1.1.2 h1:n9M7H9HfAqp+6OA98wXGMdcAr6omshSNVct65Bks1lQ=
github.com/metakeule/fmtdate v1.1.2/go.mod h1:2JyMFlKxeoGy1qS6obQukT0AL0Y4iNANQL8scbSdT4E=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -75,6 +83,10 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/phpdave11/gofpdi v1.0.14-0.20211212211723-1f10f9844311 h1:zyWXQ6vu27ETMpYsEMAsisQ+GqJ4e1TPvSNfdOPF0no=
github.com/phpdave11/gofpdi v1.0.14-0.20211212211723-1f10f9844311/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
@@ -86,6 +98,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/signintech/gopdf v0.37.0 h1:3FsZQ5WlLmnKHyc5inqCxpUn9yBDCST5INuNpXCJToM=
github.com/signintech/gopdf v0.37.0/go.mod h1:d23eO35GpEliSrF22eJ4bsM3wVeQJTjXTHq5x5qGKjA=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -102,6 +116,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/yuin/goldmark v1.8.4 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA=
github.com/yuin/goldmark v1.8.4/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
@@ -117,8 +133,10 @@ golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -44,7 +44,7 @@ func LoadFromDir(configDir string) (Config, error) {
return Config{}, err
}
if cfg.MaxUploadBytes <= 0 {
cfg.MaxUploadBytes = 32 << 20
cfg.MaxUploadBytes = 50 << 20
}
if strings.TrimSpace(cfg.StorageDir) == "" {
return Config{}, fmt.Errorf("storage_dir must not be empty")

View File

@@ -19,7 +19,7 @@ func TestDevelopmentConfigMatchesLocalWorkspaceContract(t *testing.T) {
require.Equal(t, "9150", cfg.Port)
require.Equal(t, "postgres://postgres:postgres@localhost:5432/agent_dev?sslmode=disable", cfg.DSN)
require.NotEmpty(t, strings.TrimSpace(cfg.StorageDir))
require.Equal(t, int64(32<<20), cfg.MaxUploadBytes)
require.Equal(t, int64(50<<20), cfg.MaxUploadBytes)
require.ElementsMatch(t, []string{
"http://localhost:5173",
"http://127.0.0.1:5173",
@@ -50,7 +50,7 @@ func TestLoadFromDirDefaultsToDevYAML(t *testing.T) {
require.Equal(t, "18080", cfg.Port)
require.Equal(t, "postgres://dev", cfg.DSN)
require.Equal(t, "./dev-files", cfg.StorageDir)
require.Equal(t, int64(32<<20), cfg.MaxUploadBytes)
require.Equal(t, int64(50<<20), cfg.MaxUploadBytes)
require.Equal(t, "dev-auth", cfg.AuthSecret)
require.Equal(t, "dev-system", cfg.SystemAIKey)
require.Equal(t, "dev-ai", cfg.AIKeyEncryptionSecret)

View File

@@ -14,6 +14,10 @@ type RouteRegistrar interface {
Register(router gin.IRouter)
}
type PublicRouteRegistrar interface {
RegisterPublic(router gin.IRouter)
}
// NewRouter 创建仅暴露 /api/v1 业务契约的公开路由。
func NewRouter(cfg config.Config, registrars ...RouteRegistrar) *gin.Engine {
return newRouter(cfg, nil, registrars...)
@@ -40,6 +44,11 @@ func newRouter(cfg config.Config, tokenVerifier func(string) (uint, error), regi
api.GET("/status", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"timestamp": time.Now().UTC().Format(time.RFC3339Nano)})
})
for _, registrar := range registrars {
if publicRegistrar, ok := registrar.(PublicRouteRegistrar); ok {
publicRegistrar.RegisterPublic(api)
}
}
if tokenVerifier != nil {
api.Use(auth.RequireUser(tokenVerifier))
}
@@ -77,6 +86,7 @@ func cors(cfg config.Config) gin.HandlerFunc {
c.Header("Vary", "Origin")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type")
c.Header("Access-Control-Expose-Headers", "Content-Disposition")
c.Header("Access-Control-Max-Age", "86400")
}

View File

@@ -9,7 +9,7 @@ import (
"senlinai-agent/backend/internal/logic/ai"
"senlinai-agent/backend/internal/logic/auth"
"senlinai-agent/backend/internal/logic/dataset"
"senlinai-agent/backend/internal/logic/files"
"senlinai-agent/backend/internal/logic/documents"
"senlinai-agent/backend/internal/logic/inbox"
"senlinai-agent/backend/internal/logic/projects"
"senlinai-agent/backend/internal/logic/search"
@@ -34,7 +34,7 @@ func TestMainRegistrarsRegisterTogether(t *testing.T) {
projects.NewTagHandler(projectService),
projects.NewCronHandler(projectService),
tasks.NewHandler(tasks.NewService()),
files.NewHandler(files.NewService(t.TempDir())),
documents.NewHandler(documents.NewService(nil, t.TempDir())),
inbox.NewHandler(inbox.NewService(inbox.StaticAnalyzer{})),
search.NewHandler(search.NewService(nil)),
ai.NewHandler(ai.NewSessionService(ai.NewGatewayWithSecret(cfg.SystemAIKey, cfg.AIKeyEncryptionSecret))),

View File

@@ -159,8 +159,7 @@ func TestCreateAISessionWithoutKeyReturnsAuditedErrorAndCreatesNoFormalObjects(t
for _, model := range []any{
&models.SaAISession{},
&models.SaTask{},
&models.SaNote{},
&models.SaSource{},
&models.SaDocument{},
} {
var count int64
require.NoError(t, database.Model(model).Count(&count).Error)
@@ -273,7 +272,7 @@ func TestCreateAISessionReturnsIdentityDTOAndCompleteAuditWithoutAutomaticObject
require.Equal(t, "ai_session_create", call.Action)
require.Equal(t, "ready", call.Status)
require.Empty(t, call.Error)
for _, model := range []any{&models.SaTask{}, &models.SaNote{}, &models.SaSource{}} {
for _, model := range []any{&models.SaTask{}, &models.SaDocument{}} {
var count int64
require.NoError(t, database.Model(model).Count(&count).Error)
require.Zero(t, count)

View File

@@ -62,7 +62,7 @@ type SyncResultDTO struct {
}
type DepositDTO struct {
NoteID string `json:"noteId"`
DocumentID string `json:"documentId"`
ProjectID string `json:"projectId"`
}
@@ -303,7 +303,7 @@ func (h *Handler) depositItem(c *gin.Context) {
return
}
c.JSON(http.StatusCreated, DepositDTO{
NoteID: result.NoteIdentity, ProjectID: result.ProjectIdentity,
DocumentID: result.DocumentIdentity, ProjectID: result.ProjectIdentity,
})
}

View File

@@ -90,11 +90,13 @@ func TestDatasetAPIConnectsSourcesItemsAndSyncTasks(t *testing.T) {
"projectId": project.Identity,
})
require.Equal(t, http.StatusCreated, deposited.Code)
var depositedNote models.SaNote
require.NoError(t, database.Where("project_id = ?", project.ID).First(&depositedNote).Error)
require.Equal(t, item.Title, depositedNote.Title)
require.Contains(t, depositedNote.Markdown, "![文章图片](https://example.com/article.jpg)")
require.Contains(t, depositedNote.Markdown, "原文链接")
var depositedDocument models.SaDocument
require.NoError(t, database.Where("project_id = ?", project.ID).First(&depositedDocument).Error)
require.Equal(t, item.Title+".md", depositedDocument.Name)
var depositedContent models.SaDocumentContent
require.NoError(t, database.Where("document_id = ?", depositedDocument.ID).First(&depositedContent).Error)
require.Contains(t, depositedContent.Markdown, "![文章图片](https://example.com/article.jpg)")
require.Contains(t, depositedContent.Markdown, "原文链接")
firstSync := performDatasetRequest(t, ownerRouter, http.MethodPost, "/api/v1/dataset-sources/sync", map[string]any{})
require.Equal(t, http.StatusOK, firstSync.Code)
@@ -239,7 +241,8 @@ func newDatasetTestDatabase(t *testing.T) *gorm.DB {
&models.SaDatasetSource{},
&models.SaDatasetItem{},
&models.SaProject{},
&models.SaNote{},
&models.SaDocument{},
&models.SaDocumentContent{},
))
return database
}

View File

@@ -74,7 +74,7 @@ type ItemPatch struct {
}
type DepositResult struct {
NoteIdentity string
DocumentIdentity string
ProjectIdentity string
}
@@ -359,14 +359,27 @@ func (s *Service) DepositItem(userID uint, itemIdentity, projectIdentity string)
}
body += "[原文链接](" + item.URL + ")"
}
note := models.SaNote{
ProjectID: project.ID, CreatedBy: userID,
Title: item.Title, Markdown: body,
name := strings.TrimSpace(item.Title)
if !strings.HasSuffix(strings.ToLower(name), ".md") {
name += ".md"
}
if err := s.db.Create(&note).Error; err != nil {
var document models.SaDocument
err = s.db.Transaction(func(tx *gorm.DB) error {
document = models.SaDocument{
ProjectID: project.ID, CreatedBy: userID, Kind: models.DocumentKindFile,
Name: name, NormalizedName: strings.ToLower(name), Extension: ".md",
MimeType: "text/markdown; charset=utf-8", Size: int64(len([]byte(body))),
Revision: 1, ScanStatus: "not_scanned",
}
if err := tx.Create(&document).Error; err != nil {
return err
}
return tx.Create(&models.SaDocumentContent{DocumentID: document.ID, Markdown: body}).Error
})
if err != nil {
return DepositResult{}, err
}
return DepositResult{NoteIdentity: note.Identity, ProjectIdentity: project.Identity}, nil
return DepositResult{DocumentIdentity: document.Identity, ProjectIdentity: project.Identity}, nil
}
func (s *Service) SyncSources(ctx context.Context, userID uint) ([]SyncResult, error) {

Binary file not shown.

View File

@@ -0,0 +1,93 @@
Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -0,0 +1,557 @@
package documents
import (
"bytes"
"errors"
"fmt"
"html"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/microcosm-cc/bluemonday"
"github.com/yuin/goldmark"
"gorm.io/gorm"
"senlinai-agent/backend/internal/httpx"
"senlinai-agent/backend/internal/logic/auth"
"senlinai-agent/backend/internal/models"
)
const DefaultMaxUploadBytes int64 = 50 << 20
type Handler struct {
service *Service
maxUploadBytes int64
}
type DocumentDTO struct {
ID string `json:"id"`
ProjectID string `json:"projectId"`
ParentID *string `json:"parentId"`
Kind string `json:"kind"`
Name string `json:"name"`
Extension string `json:"extension"`
MimeType string `json:"mimeType"`
Size int64 `json:"size"`
Revision uint64 `json:"revision"`
Markdown *string `json:"markdown,omitempty"`
HasBlob bool `json:"hasBlob"`
SourceInboxItemID *string `json:"sourceInboxItemId"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type ShareDTO struct {
ID string `json:"id"`
URL string `json:"url,omitempty"`
ExpiresAt time.Time `json:"expiresAt"`
RevokedAt *time.Time `json:"revokedAt"`
}
type createRequest struct {
Name string `json:"name"`
ParentID string `json:"parentId"`
Markdown string `json:"markdown"`
SourceInboxItemID string `json:"sourceInboxItemId"`
}
type updateRequest struct {
Name *string `json:"name"`
ParentID *string `json:"parentId"`
Markdown *string `json:"markdown"`
Revision *uint64 `json:"revision"`
}
type shareRequest struct {
ExpiresInDays int `json:"expiresInDays"`
}
func NewHandler(service *Service, limits ...int64) *Handler {
limit := DefaultMaxUploadBytes
if len(limits) > 0 && limits[0] > 0 {
limit = limits[0]
}
return &Handler{service: service, maxUploadBytes: limit}
}
func (h *Handler) RegisterPublic(router gin.IRouter) {
router.GET("/document-shares/:token", h.sharedMetadata)
router.GET("/document-shares/:token/view", h.sharedView)
router.GET("/document-shares/:token/content", h.sharedContent)
}
func (h *Handler) Register(router gin.IRouter) {
router.GET("/projects/:id/documents", h.list)
router.POST("/projects/:id/documents/folders", h.createFolder)
router.POST("/projects/:id/documents/markdown", h.createMarkdown)
router.POST("/projects/:id/documents/uploads", h.upload)
router.GET("/projects/:id/documents/:documentId", h.get)
router.PATCH("/projects/:id/documents/:documentId", h.update)
router.DELETE("/projects/:id/documents/:documentId", h.delete)
router.GET("/projects/:id/documents/:documentId/content", h.content)
router.GET("/projects/:id/documents/:documentId/download", h.download)
router.GET("/projects/:id/documents/:documentId/sheet-preview", h.sheetPreview)
router.GET("/projects/:id/documents/:documentId/export", h.export)
router.GET("/projects/:id/documents/:documentId/shares", h.listShares)
router.POST("/projects/:id/documents/:documentId/shares", h.createShare)
router.DELETE("/projects/:id/documents/:documentId/shares/:shareId", h.revokeShare)
}
func (h *Handler) list(c *gin.Context) {
userID, projectID, ok := requestScope(c)
if !ok {
return
}
documents, err := h.service.List(userID, projectID)
if err != nil {
writeError(c, err)
return
}
result := make([]DocumentDTO, 0, len(documents))
for _, document := range documents {
result = append(result, documentDTO(document, nil, false))
}
c.JSON(http.StatusOK, result)
}
func (h *Handler) createFolder(c *gin.Context) {
h.create(c, true)
}
func (h *Handler) createMarkdown(c *gin.Context) {
h.create(c, false)
}
func (h *Handler) create(c *gin.Context, folder bool) {
userID, projectID, ok := requestScope(c)
if !ok {
return
}
var request createRequest
if err := c.ShouldBindJSON(&request); err != nil {
httpx.Error(c, http.StatusBadRequest, "invalid_request", "文档参数无效")
return
}
input := CreateInput{Name: request.Name, ParentIdentity: request.ParentID, Markdown: request.Markdown}
if request.SourceInboxItemID != "" {
var inbox models.SaInboxItem
if err := h.service.database().Select("id").Where("identity = ?", request.SourceInboxItemID).First(&inbox).Error; err != nil {
writeError(c, gorm.ErrRecordNotFound)
return
}
input.SourceInboxItemID = &inbox.ID
}
var document *models.SaDocument
var err error
if folder {
document, err = h.service.CreateFolder(userID, projectID, input)
} else {
document, err = h.service.CreateMarkdown(userID, projectID, input)
}
if err != nil {
writeError(c, err)
return
}
markdown := (*string)(nil)
if !folder {
markdown = &input.Markdown
}
c.JSON(http.StatusCreated, documentDTO(*document, markdown, false))
}
func (h *Handler) upload(c *gin.Context) {
userID, projectID, ok := requestScope(c)
if !ok {
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, h.maxUploadBytes+(1<<20))
header, err := c.FormFile("file")
if err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
httpx.Error(c, http.StatusRequestEntityTooLarge, "payload_too_large", "单个文件不能超过 50 MB")
return
}
httpx.Error(c, http.StatusBadRequest, "invalid_request", "请选择要上传的文件")
return
}
if header.Size > h.maxUploadBytes {
httpx.Error(c, http.StatusRequestEntityTooLarge, "payload_too_large", "单个文件不能超过 50 MB")
return
}
file, err := header.Open()
if err != nil {
writeError(c, err)
return
}
defer file.Close()
parentID := c.PostForm("parentId")
if strings.EqualFold(strings.TrimSpace(filepathExtension(header.Filename)), ".md") {
data, err := io.ReadAll(io.LimitReader(file, h.maxUploadBytes+1))
if err != nil {
writeError(c, err)
return
}
if int64(len(data)) > h.maxUploadBytes {
httpx.Error(c, http.StatusRequestEntityTooLarge, "payload_too_large", "单个文件不能超过 50 MB")
return
}
document, err := h.service.CreateMarkdown(userID, projectID, CreateInput{
Name: header.Filename, ParentIdentity: parentID, Markdown: string(data),
})
if err != nil {
writeError(c, err)
return
}
markdown := string(data)
c.JSON(http.StatusCreated, documentDTO(*document, &markdown, false))
return
}
stored, err := h.service.Store(projectID, header.Filename, file)
if err != nil {
writeError(c, err)
return
}
document, err := h.service.CreateUploaded(userID, projectID, parentID, stored)
if err != nil {
writeError(c, err)
return
}
c.JSON(http.StatusCreated, documentDTO(*document, nil, true))
}
func (h *Handler) get(c *gin.Context) {
loaded, ok := h.load(c)
if !ok {
return
}
var markdown *string
if loaded.Content != nil {
markdown = &loaded.Content.Markdown
}
c.JSON(http.StatusOK, documentDTO(loaded.Document, markdown, loaded.Blob != nil))
}
func (h *Handler) update(c *gin.Context) {
userID, projectID, ok := requestScope(c)
if !ok {
return
}
var request updateRequest
if err := c.ShouldBindJSON(&request); err != nil {
httpx.Error(c, http.StatusBadRequest, "invalid_request", "文档参数无效")
return
}
loaded, err := h.service.Update(userID, projectID, c.Param("documentId"), UpdateInput{
Name: request.Name, ParentIdentity: request.ParentID, Markdown: request.Markdown, Revision: request.Revision,
})
if err != nil {
writeError(c, err)
return
}
var markdown *string
if loaded.Content != nil {
markdown = &loaded.Content.Markdown
}
c.JSON(http.StatusOK, documentDTO(loaded.Document, markdown, loaded.Blob != nil))
}
func (h *Handler) delete(c *gin.Context) {
userID, projectID, ok := requestScope(c)
if !ok {
return
}
if err := h.service.Delete(userID, projectID, c.Param("documentId")); err != nil {
writeError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *Handler) content(c *gin.Context) {
loaded, ok := h.load(c)
if !ok {
return
}
h.writeContent(c, loaded, false)
}
func (h *Handler) download(c *gin.Context) {
loaded, ok := h.load(c)
if !ok {
return
}
h.writeContent(c, loaded, true)
}
func (h *Handler) sheetPreview(c *gin.Context) {
loaded, ok := h.load(c)
if !ok {
return
}
preview, err := h.service.PreviewLegacySheet(*loaded, 200, 50)
if err != nil {
writeError(c, err)
return
}
c.JSON(http.StatusOK, preview)
}
func (h *Handler) writeContent(c *gin.Context, loaded *SharedDocument, download bool) {
if loaded.Content != nil {
disposition := "inline"
if download {
disposition = "attachment"
}
c.Header("Content-Disposition", contentDisposition(disposition, loaded.Document.Name))
c.Data(http.StatusOK, "text/markdown; charset=utf-8", []byte(loaded.Content.Markdown))
return
}
if loaded.Blob == nil {
writeError(c, ErrBlobNotFound)
return
}
file, err := h.service.OpenBlob(*loaded.Blob)
if err != nil {
writeError(c, err)
return
}
defer file.Close()
disposition := "inline"
if download {
disposition = "attachment"
}
c.Header("Content-Disposition", contentDisposition(disposition, loaded.Document.Name))
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Content-Security-Policy", "sandbox; default-src 'none'; img-src 'self' data:; media-src 'self'")
c.DataFromReader(http.StatusOK, loaded.Document.Size, loaded.Document.MimeType, file, nil)
}
func (h *Handler) export(c *gin.Context) {
loaded, ok := h.load(c)
if !ok {
return
}
data, name, contentType, err := h.service.Export(*loaded, c.Query("format"))
if err != nil {
writeError(c, err)
return
}
c.Header("Content-Disposition", contentDisposition("attachment", name))
c.Data(http.StatusOK, contentType, data)
}
func (h *Handler) createShare(c *gin.Context) {
userID, projectID, ok := requestScope(c)
if !ok {
return
}
var request shareRequest
if err := c.ShouldBindJSON(&request); err != nil {
httpx.Error(c, http.StatusBadRequest, "invalid_request", "分享参数无效")
return
}
if request.ExpiresInDays == 0 {
request.ExpiresInDays = 7
}
share, token, err := h.service.CreateShare(userID, projectID, c.Param("documentId"), time.Duration(request.ExpiresInDays)*24*time.Hour)
if err != nil {
writeError(c, err)
return
}
baseURL := strings.TrimSuffix(requestBaseURL(c), "/")
c.JSON(http.StatusCreated, ShareDTO{
ID: share.Identity, URL: baseURL + "/api/v1/document-shares/" + token + "/view", ExpiresAt: share.ExpiresAt.UTC(),
})
}
func (h *Handler) listShares(c *gin.Context) {
userID, projectID, ok := requestScope(c)
if !ok {
return
}
shares, err := h.service.ListShares(userID, projectID, c.Param("documentId"))
if err != nil {
writeError(c, err)
return
}
result := make([]ShareDTO, 0, len(shares))
for _, share := range shares {
result = append(result, ShareDTO{
ID: share.Identity, ExpiresAt: share.ExpiresAt.UTC(), RevokedAt: share.RevokedAt,
})
}
c.JSON(http.StatusOK, result)
}
func (h *Handler) revokeShare(c *gin.Context) {
userID, projectID, ok := requestScope(c)
if !ok {
return
}
if err := h.service.RevokeShare(userID, projectID, c.Param("documentId"), c.Param("shareId")); err != nil {
writeError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *Handler) sharedMetadata(c *gin.Context) {
loaded, err := h.service.ResolveShare(c.Param("token"))
if err != nil {
writeError(c, ErrShareNotFound)
return
}
shareHeaders(c)
var markdown *string
if loaded.Content != nil {
markdown = &loaded.Content.Markdown
}
dto := documentDTO(loaded.Document, markdown, loaded.Blob != nil)
dto.ProjectID = ""
dto.ParentID = nil
dto.SourceInboxItemID = nil
c.JSON(http.StatusOK, dto)
}
func (h *Handler) sharedView(c *gin.Context) {
token := c.Param("token")
loaded, err := h.service.ResolveShare(token)
if err != nil {
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(http.StatusNotFound, "<!doctype html><meta charset=\"utf-8\"><title>链接无效</title><p>分享链接不存在或已过期。</p>")
return
}
shareHeaders(c)
contentURL := strings.TrimSuffix(requestBaseURL(c), "/") + "/api/v1/document-shares/" + url.PathEscape(token) + "/content"
body := sharedPreviewBody(*loaded, contentURL)
page := `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">` +
`<title>` + html.EscapeString(loaded.Document.Name) + `</title><style>` +
`*{box-sizing:border-box}body{margin:0;background:#f6f7f9;color:#1d2129;font:15px/1.7 system-ui,-apple-system,"Segoe UI",sans-serif}` +
`main{max-width:980px;min-height:100vh;margin:auto;padding:36px 48px;background:#fff}h1{margin:0 0 28px;font-size:22px}` +
`article{overflow-wrap:anywhere}pre{overflow:auto;padding:16px;background:#f7f8fa;border-radius:8px}code{font-family:ui-monospace,monospace}` +
`img,video{display:block;max-width:100%;margin:auto}iframe{width:100%;height:calc(100vh - 130px);border:1px solid #e5e6eb}` +
`audio{width:100%}.meta{color:#86909c}@media(max-width:640px){main{padding:24px 18px}}</style></head><body><main><h1>` +
html.EscapeString(loaded.Document.Name) + `</h1>` + body + `</main></body></html>`
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(page))
}
func sharedPreviewBody(loaded SharedDocument, contentURL string) string {
safeURL := html.EscapeString(contentURL)
if loaded.Content != nil {
var rendered bytes.Buffer
if err := goldmark.Convert([]byte(loaded.Content.Markdown), &rendered); err == nil {
return `<article>` + bluemonday.UGCPolicy().Sanitize(rendered.String()) + `</article>`
}
return `<pre>` + html.EscapeString(loaded.Content.Markdown) + `</pre>`
}
switch {
case strings.HasPrefix(loaded.Document.MimeType, "image/"):
return `<img src="` + safeURL + `" alt="` + html.EscapeString(loaded.Document.Name) + `">`
case strings.HasPrefix(loaded.Document.MimeType, "audio/"):
return `<audio src="` + safeURL + `" controls></audio>`
case strings.HasPrefix(loaded.Document.MimeType, "video/"):
return `<video src="` + safeURL + `" controls></video>`
case loaded.Document.Extension == ".pdf":
return `<iframe src="` + safeURL + `" title="` + html.EscapeString(loaded.Document.Name) + `"></iframe>`
default:
return `<p class="meta">此文件暂不支持在线预览。</p>`
}
}
func (h *Handler) sharedContent(c *gin.Context) {
loaded, err := h.service.ResolveShare(c.Param("token"))
if err != nil {
writeError(c, ErrShareNotFound)
return
}
shareHeaders(c)
h.writeContent(c, loaded, false)
}
func (h *Handler) load(c *gin.Context) (*SharedDocument, bool) {
userID, projectID, ok := requestScope(c)
if !ok {
return nil, false
}
loaded, err := h.service.Get(userID, projectID, c.Param("documentId"))
if err != nil {
writeError(c, err)
return nil, false
}
return loaded, true
}
func requestScope(c *gin.Context) (uint, string, bool) {
userID, ok := auth.CurrentUserID(c)
if !ok {
httpx.Error(c, http.StatusUnauthorized, "unauthorized", "未登录或登录已失效")
return 0, "", false
}
projectID, ok := httpx.IdentityParam(c, "id")
return userID, projectID, ok
}
func documentDTO(document models.SaDocument, markdown *string, hasBlob bool) DocumentDTO {
return DocumentDTO{
ID: document.Identity, ProjectID: document.ProjectIdentity, ParentID: document.ParentIdentity,
Kind: document.Kind, Name: document.Name, Extension: document.Extension, MimeType: document.MimeType,
Size: document.Size, Revision: document.Revision, Markdown: markdown, HasBlob: hasBlob,
SourceInboxItemID: document.SourceInboxItemIdentity,
CreatedAt: document.CreatedAt.UTC(), UpdatedAt: document.UpdatedAt.UTC(),
}
}
func writeError(c *gin.Context, err error) {
switch {
case errors.Is(err, gorm.ErrRecordNotFound), errors.Is(err, ErrShareNotFound):
httpx.Error(c, http.StatusNotFound, "not_found", "文档不存在")
case errors.Is(err, ErrNameConflict):
httpx.Error(c, http.StatusConflict, "name_conflict", "同一目录已存在同名文档")
case errors.Is(err, ErrRevisionConflict):
httpx.Error(c, http.StatusConflict, "revision_conflict", "文档已在其他窗口更新")
case errors.Is(err, ErrInvalidDocument), errors.Is(err, ErrInvalidParent), errors.Is(err, ErrUnsupportedExport):
httpx.Error(c, http.StatusBadRequest, "invalid_request", "文档参数无效")
default:
httpx.Error(c, http.StatusInternalServerError, "internal_error", "文档操作失败")
}
}
func contentDisposition(kind, name string) string {
return fmt.Sprintf(`%s; filename*=UTF-8''%s`, kind, url.PathEscape(name))
}
func requestBaseURL(c *gin.Context) string {
scheme := "http"
if c.Request.TLS != nil || strings.EqualFold(c.GetHeader("X-Forwarded-Proto"), "https") {
scheme = "https"
}
return scheme + "://" + c.Request.Host
}
func shareHeaders(c *gin.Context) {
c.Header("X-Robots-Tag", "noindex, nofollow, noarchive")
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Referrer-Policy", "no-referrer")
c.Header("Content-Security-Policy", "default-src 'none'; img-src 'self' data:; media-src 'self'; frame-src 'self'; style-src 'unsafe-inline'; frame-ancestors 'none'")
}
func filepathExtension(name string) string {
index := strings.LastIndex(name, ".")
if index < 0 {
return ""
}
return name[index:]
}
func parseInt(value string, fallback int) int {
parsed, err := strconv.Atoi(value)
if err != nil {
return fallback
}
return parsed
}

View File

@@ -0,0 +1,32 @@
package documents
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
"senlinai-agent/backend/internal/models"
)
func TestSharedPreviewBodySanitizesMarkdownHTMLAndUnsafeLinks(t *testing.T) {
body := sharedPreviewBody(SharedDocument{
Document: models.SaDocument{Name: "shared.md", Extension: ".md"},
Content: &models.SaDocumentContent{
Markdown: "# 标题\n\n<script>alert(1)</script>\n\n[危险链接](javascript:alert(2))",
},
}, "https://example.test/content")
require.Contains(t, body, "<h1>标题</h1>")
require.NotContains(t, strings.ToLower(body), "<script")
require.NotContains(t, strings.ToLower(body), "javascript:")
}
func TestSharedPreviewBodyEmbedsSupportedBlobWithoutDownloadLink(t *testing.T) {
body := sharedPreviewBody(SharedDocument{
Document: models.SaDocument{Name: "manual.pdf", Extension: ".pdf", MimeType: "application/pdf"},
Blob: &models.SaDocumentBlob{StorageKey: "blob"},
}, "https://example.test/content")
require.Contains(t, body, `<iframe src="https://example.test/content"`)
require.NotContains(t, strings.ToLower(body), "download")
}

View File

@@ -0,0 +1,799 @@
package documents
import (
"archive/zip"
"bytes"
"crypto/rand"
"crypto/sha256"
_ "embed"
"encoding/hex"
"errors"
"fmt"
"html"
"io"
"mime"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"unicode"
"github.com/eidrisov/xlsReader/xls"
"github.com/signintech/gopdf"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"senlinai-agent/backend/internal/models"
)
var (
ErrInvalidDocument = errors.New("invalid document")
ErrInvalidParent = errors.New("invalid document parent")
ErrNameConflict = errors.New("document name already exists")
ErrRevisionConflict = errors.New("document revision conflict")
ErrUnsupportedExport = errors.New("unsupported document export")
ErrShareNotFound = errors.New("document share not found")
ErrBlobNotFound = errors.New("document blob not found")
)
type Service struct {
db *gorm.DB
root string
now func() time.Time
}
type CreateInput struct {
Name string
ParentIdentity string
Markdown string
SourceInboxItemID *uint
}
type UpdateInput struct {
Name *string
ParentIdentity *string
Markdown *string
Revision *uint64
}
type StoredBlob struct {
StorageKey string
RelativePath string
AbsolutePath string
OriginalName string
MimeType string
Extension string
Size int64
Checksum string
}
type SharedDocument struct {
Document models.SaDocument
Content *models.SaDocumentContent
Blob *models.SaDocumentBlob
}
type SheetPreview struct {
SheetName string `json:"sheetName"`
Rows [][]string `json:"rows"`
Truncated bool `json:"truncated"`
}
//go:embed assets/NotoSansSC.ttf
var documentPDFFont []byte
func NewService(database *gorm.DB, root string) *Service {
return &Service{db: database, root: root, now: time.Now}
}
func (s *Service) database() *gorm.DB {
if s.db != nil {
return s.db
}
return models.DBService
}
func (s *Service) List(userID uint, projectIdentity string) ([]models.SaDocument, error) {
project, err := s.ownedProject(userID, projectIdentity)
if err != nil {
return nil, err
}
var documents []models.SaDocument
err = s.database().Where("project_id = ?", project.ID).
Order("kind asc, normalized_name asc, id asc").Find(&documents).Error
return documents, err
}
func (s *Service) CreateFolder(userID uint, projectIdentity string, input CreateInput) (*models.SaDocument, error) {
return s.createNode(userID, projectIdentity, input, models.DocumentKindFolder, "", "", 0)
}
func (s *Service) CreateMarkdown(userID uint, projectIdentity string, input CreateInput) (*models.SaDocument, error) {
if strings.TrimSpace(filepath.Ext(input.Name)) == "" {
input.Name += ".md"
}
var created *models.SaDocument
err := s.database().Transaction(func(tx *gorm.DB) error {
service := NewService(tx, s.root)
document, err := service.createNode(userID, projectIdentity, input, models.DocumentKindFile, ".md", "text/markdown; charset=utf-8", int64(len([]byte(input.Markdown))))
if err != nil {
return err
}
if err := tx.Create(&models.SaDocumentContent{DocumentID: document.ID, Markdown: input.Markdown}).Error; err != nil {
return err
}
created = document
return nil
})
return created, err
}
func (s *Service) createNode(userID uint, projectIdentity string, input CreateInput, kind, extension, mimeType string, size int64) (*models.SaDocument, error) {
project, err := s.ownedProject(userID, projectIdentity)
if err != nil {
return nil, err
}
parentID, parentIdentity, err := s.resolveParent(project.ID, input.ParentIdentity)
if err != nil {
return nil, err
}
name := cleanDisplayName(input.Name)
if name == "" || name == "." || name == ".." || strings.ContainsAny(name, `/\`) {
return nil, ErrInvalidDocument
}
name, err = s.availableName(project.ID, parentID, name)
if err != nil {
return nil, err
}
if kind == models.DocumentKindFile {
extension = strings.ToLower(filepath.Ext(name))
}
document := &models.SaDocument{
ProjectID: project.ID, ProjectIdentity: project.Identity,
ParentID: parentID, ParentIdentity: parentIdentity,
ParentScope: identityValue(parentIdentity),
CreatedBy: userID, SourceInboxItemID: input.SourceInboxItemID,
Kind: kind, Name: name, NormalizedName: normalizeName(name),
Extension: extension, MimeType: mimeType, Size: size, Revision: 1,
ScanStatus: "not_scanned",
}
if err := s.database().Create(document).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return nil, ErrNameConflict
}
return nil, err
}
return document, nil
}
func (s *Service) CreateUploaded(userID uint, projectIdentity, parentIdentity string, stored StoredBlob) (*models.SaDocument, error) {
var created *models.SaDocument
err := s.database().Transaction(func(tx *gorm.DB) error {
service := NewService(tx, s.root)
document, err := service.createNode(userID, projectIdentity, CreateInput{
Name: stored.OriginalName, ParentIdentity: parentIdentity,
}, models.DocumentKindFile, stored.Extension, stored.MimeType, stored.Size)
if err != nil {
return err
}
if err := tx.Create(&models.SaDocumentBlob{
DocumentID: document.ID, StorageKey: stored.StorageKey, RelativePath: stored.RelativePath,
OriginalName: stored.OriginalName, Checksum: stored.Checksum,
}).Error; err != nil {
return err
}
created = document
return nil
})
if err != nil {
_ = os.Remove(stored.AbsolutePath)
}
return created, err
}
func (s *Service) Get(userID uint, projectIdentity, documentIdentity string) (*SharedDocument, error) {
project, err := s.ownedProject(userID, projectIdentity)
if err != nil {
return nil, err
}
var document models.SaDocument
if err := s.database().Where("identity = ? AND project_id = ?", strings.TrimSpace(documentIdentity), project.ID).First(&document).Error; err != nil {
return nil, err
}
return s.loadDocument(document)
}
func (s *Service) Update(userID uint, projectIdentity, documentIdentity string, input UpdateInput) (*SharedDocument, error) {
var updated *SharedDocument
err := s.database().Transaction(func(tx *gorm.DB) error {
service := NewService(tx, s.root)
project, err := service.ownedProject(userID, projectIdentity)
if err != nil {
return err
}
var document models.SaDocument
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
Where("identity = ? AND project_id = ?", strings.TrimSpace(documentIdentity), project.ID).
First(&document).Error; err != nil {
return err
}
updates := map[string]any{}
if input.Name != nil {
name := cleanDisplayName(*input.Name)
if name == "" || strings.ContainsAny(name, `/\`) {
return ErrInvalidDocument
}
if normalizeName(name) != document.NormalizedName {
if service.nameExists(project.ID, document.ParentID, normalizeName(name), document.ID) {
return ErrNameConflict
}
updates["name"] = name
updates["normalized_name"] = normalizeName(name)
if document.Kind == models.DocumentKindFile {
updates["extension"] = strings.ToLower(filepath.Ext(name))
}
}
}
if input.ParentIdentity != nil {
parentID, parentIdentity, err := service.resolveParent(project.ID, *input.ParentIdentity)
if err != nil {
return err
}
if parentID != nil && *parentID == document.ID {
return ErrInvalidParent
}
if document.Kind == models.DocumentKindFolder && parentID != nil {
descendant, err := service.isDescendant(document.ID, *parentID)
if err != nil {
return err
}
if descendant {
return ErrInvalidParent
}
}
nextName := document.NormalizedName
if value, ok := updates["normalized_name"].(string); ok {
nextName = value
}
if service.nameExists(project.ID, parentID, nextName, document.ID) {
return ErrNameConflict
}
updates["parent_id"] = parentID
updates["parent_identity"] = parentIdentity
updates["parent_scope"] = identityValue(parentIdentity)
}
if input.Markdown != nil {
if document.Kind != models.DocumentKindFile || document.Extension != ".md" || input.Revision == nil {
return ErrInvalidDocument
}
if document.Revision != *input.Revision {
return ErrRevisionConflict
}
var content models.SaDocumentContent
err := tx.Where("document_id = ?", document.ID).First(&content).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
content = models.SaDocumentContent{DocumentID: document.ID, Markdown: *input.Markdown}
err = tx.Create(&content).Error
} else if err == nil {
err = tx.Model(&content).Update("markdown", *input.Markdown).Error
}
if err != nil {
return err
}
updates["size"] = int64(len([]byte(*input.Markdown)))
updates["revision"] = document.Revision + 1
}
if len(updates) > 0 {
if err := tx.Model(&document).Updates(updates).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return ErrNameConflict
}
return err
}
}
if err := tx.Where("id = ?", document.ID).First(&document).Error; err != nil {
return err
}
updated, err = service.loadDocument(document)
return err
})
return updated, err
}
func (s *Service) Delete(userID uint, projectIdentity, documentIdentity string) error {
project, err := s.ownedProject(userID, projectIdentity)
if err != nil {
return err
}
var document models.SaDocument
if err := s.database().Where("identity = ? AND project_id = ?", documentIdentity, project.ID).First(&document).Error; err != nil {
return err
}
ids, err := s.subtreeIDs(project.ID, document.ID)
if err != nil {
return err
}
return s.database().Where("id IN ?", ids).Delete(&models.SaDocument{}).Error
}
func (s *Service) Store(projectIdentity, originalName string, source io.Reader) (StoredBlob, error) {
projectSegment := filepath.Base(strings.TrimSpace(projectIdentity))
if projectSegment == "." || projectSegment == "" || projectSegment != strings.TrimSpace(projectIdentity) {
return StoredBlob{}, ErrInvalidDocument
}
name := cleanDisplayName(originalName)
if name == "" {
name = "upload.bin"
}
directoryRelative := filepath.ToSlash(filepath.Join("projects", projectSegment, "documents"))
directoryAbsolute, err := s.absolutePath(directoryRelative)
if err != nil {
return StoredBlob{}, err
}
if err := os.MkdirAll(directoryAbsolute, 0o755); err != nil {
return StoredBlob{}, err
}
temp, err := os.CreateTemp(directoryAbsolute, ".upload-*")
if err != nil {
return StoredBlob{}, err
}
tempName := temp.Name()
defer func() { _ = os.Remove(tempName) }()
hasher := sha256.New()
var sniff bytes.Buffer
writer := io.MultiWriter(temp, hasher, &limitedWriter{writer: &sniff, remaining: 512})
size, copyErr := io.Copy(writer, source)
closeErr := temp.Close()
if copyErr != nil {
return StoredBlob{}, copyErr
}
if closeErr != nil {
return StoredBlob{}, closeErr
}
keyBytes := make([]byte, 16)
if _, err := rand.Read(keyBytes); err != nil {
return StoredBlob{}, err
}
key := hex.EncodeToString(keyBytes)
relative := filepath.ToSlash(filepath.Join(directoryRelative, key))
absolute, err := s.absolutePath(relative)
if err != nil {
return StoredBlob{}, err
}
if err := os.Rename(tempName, absolute); err != nil {
return StoredBlob{}, err
}
detected := http.DetectContentType(sniff.Bytes())
extension := strings.ToLower(filepath.Ext(name))
if extensionType := mime.TypeByExtension(extension); extensionType != "" && detected == "application/octet-stream" {
detected = extensionType
}
return StoredBlob{
StorageKey: key, RelativePath: relative, AbsolutePath: absolute, OriginalName: name,
MimeType: detected, Extension: extension, Size: size, Checksum: hex.EncodeToString(hasher.Sum(nil)),
}, nil
}
func (s *Service) OpenBlob(blob models.SaDocumentBlob) (*os.File, error) {
absolute, err := s.absolutePath(blob.RelativePath)
if err != nil {
return nil, err
}
return os.Open(absolute)
}
func (s *Service) PreviewLegacySheet(loaded SharedDocument, maxRows, maxColumns int) (SheetPreview, error) {
if loaded.Document.Extension != ".xls" || loaded.Blob == nil {
return SheetPreview{}, ErrInvalidDocument
}
file, err := s.OpenBlob(*loaded.Blob)
if err != nil {
return SheetPreview{}, err
}
defer file.Close()
workbook, err := xls.OpenReader(file)
if err != nil {
return SheetPreview{}, ErrInvalidDocument
}
sheet, err := workbook.GetSheet(0)
if err != nil {
return SheetPreview{}, ErrInvalidDocument
}
sourceRows := sheet.GetRows()
preview := SheetPreview{SheetName: sheet.GetName(), Truncated: len(sourceRows) > maxRows}
if len(sourceRows) > maxRows {
sourceRows = sourceRows[:maxRows]
}
preview.Rows = make([][]string, 0, len(sourceRows))
for _, sourceRow := range sourceRows {
columns := sourceRow.GetCols()
if len(columns) > maxColumns {
columns = columns[:maxColumns]
preview.Truncated = true
}
row := make([]string, 0, len(columns))
for _, column := range columns {
row = append(row, column.GetString())
}
preview.Rows = append(preview.Rows, row)
}
return preview, nil
}
func (s *Service) CreateShare(userID uint, projectIdentity, documentIdentity string, duration time.Duration) (*models.SaDocumentShare, string, error) {
loaded, err := s.Get(userID, projectIdentity, documentIdentity)
if err != nil {
return nil, "", err
}
if loaded.Document.Kind != models.DocumentKindFile {
return nil, "", ErrInvalidDocument
}
if duration <= 0 || duration > 30*24*time.Hour {
return nil, "", ErrInvalidDocument
}
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
return nil, "", err
}
token := hex.EncodeToString(tokenBytes)
hash := sha256.Sum256([]byte(token))
share := &models.SaDocumentShare{
DocumentID: loaded.Document.ID, CreatedBy: userID, TokenHash: hex.EncodeToString(hash[:]),
ExpiresAt: s.now().UTC().Add(duration),
}
return share, token, s.database().Create(share).Error
}
func (s *Service) ListShares(userID uint, projectIdentity, documentIdentity string) ([]models.SaDocumentShare, error) {
loaded, err := s.Get(userID, projectIdentity, documentIdentity)
if err != nil {
return nil, err
}
var shares []models.SaDocumentShare
err = s.database().Where("document_id = ?", loaded.Document.ID).Order("created_at desc").Find(&shares).Error
return shares, err
}
func (s *Service) RevokeShare(userID uint, projectIdentity, documentIdentity, shareIdentity string) error {
loaded, err := s.Get(userID, projectIdentity, documentIdentity)
if err != nil {
return err
}
now := s.now().UTC()
result := s.database().Model(&models.SaDocumentShare{}).
Where("identity = ? AND document_id = ?", shareIdentity, loaded.Document.ID).
Update("revoked_at", &now)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
func (s *Service) ResolveShare(token string) (*SharedDocument, error) {
hash := sha256.Sum256([]byte(strings.TrimSpace(token)))
var share models.SaDocumentShare
if err := s.database().Where(
"token_hash = ? AND revoked_at IS NULL AND expires_at > ?",
hex.EncodeToString(hash[:]), s.now().UTC(),
).First(&share).Error; err != nil {
return nil, ErrShareNotFound
}
var document models.SaDocument
if err := s.database().Where("id = ?", share.DocumentID).First(&document).Error; err != nil {
return nil, ErrShareNotFound
}
return s.loadDocument(document)
}
func (s *Service) Export(document SharedDocument, format string) ([]byte, string, string, error) {
if document.Content == nil || document.Document.Extension != ".md" {
return nil, "", "", ErrUnsupportedExport
}
base := strings.TrimSuffix(document.Document.Name, filepath.Ext(document.Document.Name))
switch strings.ToLower(strings.TrimSpace(format)) {
case "md", "markdown":
return []byte(document.Content.Markdown), base + ".md", "text/markdown; charset=utf-8", nil
case "docx":
data, err := markdownDOCX(document.Content.Markdown)
return data, base + ".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", err
case "pdf":
data, err := markdownPDF(document.Content.Markdown)
return data, base + ".pdf", "application/pdf", err
default:
return nil, "", "", ErrUnsupportedExport
}
}
func (s *Service) loadDocument(document models.SaDocument) (*SharedDocument, error) {
result := &SharedDocument{Document: document}
var content models.SaDocumentContent
if err := s.database().Where("document_id = ?", document.ID).First(&content).Error; err == nil {
result.Content = &content
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
var blob models.SaDocumentBlob
if err := s.database().Where("document_id = ?", document.ID).First(&blob).Error; err == nil {
result.Blob = &blob
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
return result, nil
}
func (s *Service) ownedProject(userID uint, identity string) (*models.SaProject, error) {
var project models.SaProject
err := s.database().Where("identity = ? AND owner_id = ?", strings.TrimSpace(identity), userID).First(&project).Error
return &project, err
}
func (s *Service) resolveParent(projectID uint, identity string) (*uint, *string, error) {
identity = strings.TrimSpace(identity)
if identity == "" {
return nil, nil, nil
}
var parent models.SaDocument
if err := s.database().Where("identity = ? AND project_id = ? AND kind = ?", identity, projectID, models.DocumentKindFolder).First(&parent).Error; err != nil {
return nil, nil, ErrInvalidParent
}
return &parent.ID, &parent.Identity, nil
}
func (s *Service) availableName(projectID uint, parentID *uint, name string) (string, error) {
extension := filepath.Ext(name)
base := strings.TrimSuffix(name, extension)
for attempt := 0; attempt < 1000; attempt++ {
candidate := name
if attempt > 0 {
candidate = fmt.Sprintf("%s (%d)%s", base, attempt, extension)
}
if !s.nameExists(projectID, parentID, normalizeName(candidate), 0) {
return candidate, nil
}
}
return "", ErrNameConflict
}
func (s *Service) nameExists(projectID uint, parentID *uint, normalized string, exceptID uint) bool {
query := s.database().Model(&models.SaDocument{}).Where("project_id = ? AND normalized_name = ?", projectID, normalized)
if parentID == nil {
query = query.Where("parent_id IS NULL")
} else {
query = query.Where("parent_id = ?", *parentID)
}
if exceptID > 0 {
query = query.Where("id <> ?", exceptID)
}
var count int64
return query.Count(&count).Error == nil && count > 0
}
func (s *Service) isDescendant(ancestorID, candidateID uint) (bool, error) {
current := candidateID
for current != 0 {
if current == ancestorID {
return true, nil
}
var document models.SaDocument
if err := s.database().Unscoped().Select("parent_id").Where("id = ?", current).First(&document).Error; err != nil {
return false, err
}
if document.ParentID == nil {
return false, nil
}
current = *document.ParentID
}
return false, nil
}
func (s *Service) subtreeIDs(projectID, rootID uint) ([]uint, error) {
result := []uint{rootID}
frontier := []uint{rootID}
for len(frontier) > 0 {
var children []models.SaDocument
if err := s.database().Select("id").Where("project_id = ? AND parent_id IN ?", projectID, frontier).Find(&children).Error; err != nil {
return nil, err
}
frontier = frontier[:0]
for _, child := range children {
result = append(result, child.ID)
frontier = append(frontier, child.ID)
}
}
return result, nil
}
func (s *Service) absolutePath(relative string) (string, error) {
cleaned := filepath.Clean(filepath.FromSlash(strings.TrimSpace(relative)))
if cleaned == "." || filepath.IsAbs(cleaned) || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) {
return "", ErrInvalidDocument
}
root, err := filepath.Abs(s.root)
if err != nil {
return "", err
}
absolute, err := filepath.Abs(filepath.Join(root, cleaned))
if err != nil {
return "", err
}
relativeToRoot, err := filepath.Rel(root, absolute)
if err != nil || relativeToRoot == ".." || strings.HasPrefix(relativeToRoot, ".."+string(filepath.Separator)) {
return "", ErrInvalidDocument
}
return absolute, nil
}
func cleanDisplayName(value string) string {
return strings.TrimSpace(filepath.Base(strings.ReplaceAll(value, "\\", "/")))
}
func normalizeName(value string) string {
return strings.Map(func(r rune) rune {
if unicode.IsSpace(r) {
return ' '
}
return unicode.ToLower(r)
}, strings.TrimSpace(value))
}
func identityValue(value *string) string {
if value == nil {
return ""
}
return *value
}
type limitedWriter struct {
writer io.Writer
remaining int
}
func (w *limitedWriter) Write(p []byte) (int, error) {
if w.remaining <= 0 {
return len(p), nil
}
size := len(p)
if size > w.remaining {
size = w.remaining
}
_, _ = w.writer.Write(p[:size])
w.remaining -= size
return len(p), nil
}
var markdownPrefix = regexp.MustCompile(`(?m)^#{1,6}\s+|^\s*[-*+]\s+|^\s*\d+\.\s+|^\s*>\s?`)
func plainMarkdown(value string) string {
value = markdownPrefix.ReplaceAllString(value, "")
value = strings.NewReplacer("**", "", "__", "", "`", "", "\r", "").Replace(value)
return value
}
func markdownDOCX(markdown string) ([]byte, error) {
var buffer bytes.Buffer
archive := zip.NewWriter(&buffer)
files := map[string]string{
"[Content_Types].xml": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>`,
"_rels/.rels": `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>`,
"word/document.xml": docxDocumentXML(markdown),
}
for name, body := range files {
writer, err := archive.Create(name)
if err != nil {
return nil, err
}
if _, err := io.WriteString(writer, body); err != nil {
return nil, err
}
}
if err := archive.Close(); err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
func docxDocumentXML(markdown string) string {
var paragraphs strings.Builder
for _, line := range strings.Split(plainMarkdown(markdown), "\n") {
paragraphs.WriteString(`<w:p><w:r><w:t xml:space="preserve">`)
paragraphs.WriteString(html.EscapeString(line))
paragraphs.WriteString(`</w:t></w:r></w:p>`)
}
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>` +
paragraphs.String() + `<w:sectPr><w:pgSz w:w="11906" w:h="16838"/><w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440"/></w:sectPr></w:body></w:document>`
}
func markdownPDF(markdown string) ([]byte, error) {
const (
pageWidth = 595.28
pageHeight = 841.89
marginX = 52.0
top = 56.0
bottom = 60.0
)
pdf := &gopdf.GoPdf{}
pdf.Start(gopdf.Config{PageSize: gopdf.Rect{W: pageWidth, H: pageHeight}})
if err := pdf.AddTTFFontData("NotoSansSC", documentPDFFont); err != nil {
return nil, fmt.Errorf("load document PDF font: %w", err)
}
pageNumber := 0
y := top
addPage := func() error {
if pageNumber > 0 {
if err := writePDFFooter(pdf, pageNumber, pageWidth, pageHeight); err != nil {
return err
}
}
pdf.AddPage()
pageNumber++
y = top
return nil
}
if err := addPage(); err != nil {
return nil, err
}
for _, rawLine := range strings.Split(strings.ReplaceAll(markdown, "\r", ""), "\n") {
line := plainMarkdown(rawLine)
fontSize := 11.0
lineHeight := 18.0
switch {
case strings.HasPrefix(rawLine, "# "):
fontSize, lineHeight = 22, 32
case strings.HasPrefix(rawLine, "## "):
fontSize, lineHeight = 18, 27
case strings.HasPrefix(rawLine, "### "):
fontSize, lineHeight = 15, 23
case strings.TrimSpace(rawLine) == "":
y += 9
continue
}
if err := pdf.SetFont("NotoSansSC", "", fontSize); err != nil {
return nil, err
}
lines, err := pdf.SplitTextWithWordWrap(line, pageWidth-2*marginX)
if err != nil {
return nil, err
}
if len(lines) == 0 {
lines = []string{""}
}
for _, wrapped := range lines {
if y+lineHeight > pageHeight-bottom {
if err := addPage(); err != nil {
return nil, err
}
}
pdf.SetX(marginX)
pdf.SetY(y)
if err := pdf.Cell(&gopdf.Rect{W: pageWidth - 2*marginX, H: lineHeight}, wrapped); err != nil {
return nil, err
}
y += lineHeight
}
y += 3
}
if err := writePDFFooter(pdf, pageNumber, pageWidth, pageHeight); err != nil {
return nil, err
}
var buffer bytes.Buffer
if err := pdf.Write(&buffer); err != nil {
return nil, err
}
return buffer.Bytes(), nil
}
func writePDFFooter(pdf *gopdf.GoPdf, pageNumber int, pageWidth, pageHeight float64) error {
if err := pdf.SetFont("NotoSansSC", "", 9); err != nil {
return err
}
pdf.SetTextColor(120, 120, 120)
pdf.SetX(0)
pdf.SetY(pageHeight - 34)
err := pdf.CellWithOption(&gopdf.Rect{W: pageWidth, H: 14}, fmt.Sprintf("第 %d 页", pageNumber), gopdf.CellOption{Align: gopdf.Center})
pdf.SetTextColor(30, 30, 30)
return err
}

View File

@@ -0,0 +1,126 @@
package documents
import (
"bytes"
"errors"
"fmt"
"os"
"testing"
"time"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"senlinai-agent/backend/internal/models"
)
func TestDocumentTreeRevisionMoveDeleteAndShareLifecycle(t *testing.T) {
service, database, user, project := newDocumentTestService(t)
folder, err := service.CreateFolder(user.ID, project.Identity, CreateInput{Name: "产品"})
require.NoError(t, err)
child, err := service.CreateFolder(user.ID, project.Identity, CreateInput{Name: "方案", ParentIdentity: folder.Identity})
require.NoError(t, err)
document, err := service.CreateMarkdown(user.ID, project.Identity, CreateInput{
Name: "说明.md", ParentIdentity: child.Identity, Markdown: "# 第一版",
})
require.NoError(t, err)
loaded, err := service.Get(user.ID, project.Identity, document.Identity)
require.NoError(t, err)
require.Equal(t, "# 第一版", loaded.Content.Markdown)
revision := loaded.Document.Revision
nextMarkdown := "# 第二版"
updated, err := service.Update(user.ID, project.Identity, document.Identity, UpdateInput{
Markdown: &nextMarkdown, Revision: &revision,
})
require.NoError(t, err)
require.Equal(t, revision+1, updated.Document.Revision)
stale := "# 陈旧覆盖"
_, err = service.Update(user.ID, project.Identity, document.Identity, UpdateInput{
Markdown: &stale, Revision: &revision,
})
require.ErrorIs(t, err, ErrRevisionConflict)
parent := child.Identity
_, err = service.Update(user.ID, project.Identity, folder.Identity, UpdateInput{ParentIdentity: &parent})
require.ErrorIs(t, err, ErrInvalidParent)
share, token, err := service.CreateShare(user.ID, project.Identity, document.Identity, 24*time.Hour)
require.NoError(t, err)
require.NotEmpty(t, token)
require.NotEqual(t, token, share.TokenHash)
shared, err := service.ResolveShare(token)
require.NoError(t, err)
require.Equal(t, document.Identity, shared.Document.Identity)
require.NoError(t, service.RevokeShare(user.ID, project.Identity, document.Identity, share.Identity))
_, err = service.ResolveShare(token)
require.ErrorIs(t, err, ErrShareNotFound)
require.NoError(t, service.Delete(user.ID, project.Identity, folder.Identity))
var visible int64
require.NoError(t, database.Model(&models.SaDocument{}).Where("project_id = ?", project.ID).Count(&visible).Error)
require.Zero(t, visible)
var retained int64
require.NoError(t, database.Unscoped().Model(&models.SaDocument{}).Where("project_id = ?", project.ID).Count(&retained).Error)
require.Equal(t, int64(3), retained)
}
func TestDocumentUploadUsesOpaqueStorageAndMarkdownNamesAreDeduplicated(t *testing.T) {
service, _, user, project := newDocumentTestService(t)
first, err := service.CreateMarkdown(user.ID, project.Identity, CreateInput{Name: "记录.md", Markdown: "first"})
require.NoError(t, err)
second, err := service.CreateMarkdown(user.ID, project.Identity, CreateInput{Name: "记录.md", Markdown: "second"})
require.NoError(t, err)
require.Equal(t, "记录.md", first.Name)
require.Equal(t, "记录 (1).md", second.Name)
content := bytes.Repeat([]byte("pdf-content"), 128)
stored, err := service.Store(project.Identity, "../../合同.pdf", bytes.NewReader(content))
require.NoError(t, err)
require.Equal(t, "合同.pdf", stored.OriginalName)
require.NotContains(t, stored.RelativePath, "合同.pdf")
require.FileExists(t, stored.AbsolutePath)
uploaded, err := service.CreateUploaded(user.ID, project.Identity, "", stored)
require.NoError(t, err)
require.Equal(t, ".pdf", uploaded.Extension)
require.Equal(t, int64(len(content)), uploaded.Size)
file, err := os.Open(stored.AbsolutePath)
require.NoError(t, err)
defer file.Close()
}
func TestDocumentExportProducesDownloadableFormats(t *testing.T) {
service, _, user, project := newDocumentTestService(t)
document, err := service.CreateMarkdown(user.ID, project.Identity, CreateInput{Name: "导出.md", Markdown: "# 标题\n\n正文"})
require.NoError(t, err)
loaded, err := service.Get(user.ID, project.Identity, document.Identity)
require.NoError(t, err)
for _, format := range []string{"md", "docx", "pdf"} {
data, name, contentType, err := service.Export(*loaded, format)
require.NoError(t, err)
require.NotEmpty(t, data)
require.Equal(t, "导出."+format, name)
require.NotEmpty(t, contentType)
}
}
func newDocumentTestService(t *testing.T) (*Service, *gorm.DB, models.SaUser, models.SaProject) {
t.Helper()
database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{TranslateError: true})
require.NoError(t, err)
require.NoError(t, models.AutoMigrate(database))
user := models.SaUser{Email: t.Name() + "@example.com", DisplayName: "Owner", PasswordHash: "hash"}
require.NoError(t, database.Create(&user).Error)
project := models.SaProject{OwnerID: user.ID, Name: "Documents", Identifier: "DOCS-" + fmt.Sprint(time.Now().UnixNano())}
require.NoError(t, database.Create(&project).Error)
service := NewService(database, t.TempDir())
service.now = func() time.Time { return time.Date(2026, 7, 24, 0, 0, 0, 0, time.UTC) }
t.Cleanup(func() {
require.False(t, errors.Is(database.Error, gorm.ErrInvalidDB))
})
return service, database, user, project
}

View File

@@ -1,122 +0,0 @@
package files
import (
"errors"
"log"
"net/http"
"path/filepath"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"senlinai-agent/backend/internal/httpx"
"senlinai-agent/backend/internal/logic/auth"
"senlinai-agent/backend/internal/logic/projects"
"senlinai-agent/backend/internal/models"
)
// SourceDTO 是文件资料写接口的稳定响应,仅返回 opaque storage key不暴露物理目录布局。
type SourceDTO struct {
ID string `json:"id"`
ProjectID string `json:"projectId"`
Kind string `json:"kind"`
Title string `json:"title"`
StorageKey string `json:"storageKey"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// Handler 负责 multipart 边界,文件名清理和本地路径构造始终委托给 Service。
type Handler struct {
service *Service
maxUploadBytes int64
}
// DefaultMaxUploadBytes 是未显式配置时的请求体上限,覆盖完整 multipart 内容。
const DefaultMaxUploadBytes int64 = 32 << 20
// NewHandler 创建文件资料 HTTP registrar。
func NewHandler(service *Service, limits ...int64) *Handler {
limit := DefaultMaxUploadBytes
if len(limits) > 0 && limits[0] > 0 {
limit = limits[0]
}
return &Handler{service: service, maxUploadBytes: limit}
}
// Register 将文件资料上传接口注册到上层提供的 /api/v1 路由组。
func (h *Handler) Register(router gin.IRouter) {
router.POST("/projects/:id/sources", h.upload)
}
func (h *Handler) upload(c *gin.Context) {
userID, ok := auth.CurrentUserID(c)
if !ok {
httpx.Error(c, http.StatusUnauthorized, "unauthorized", "未登录或登录已失效")
return
}
projectIdentity, ok := httpx.IdentityParam(c, "id")
if !ok {
return
}
// 所有权必须在写磁盘前校验,防止越权请求留下孤立文件。
project, err := projects.FindOwnedProject(userID, projectIdentity)
if err != nil {
writeSourceError(c, err)
return
}
// MaxBytesReader 必须在任何 multipart 解析前安装,限制字段、边界和文件内容的总请求量。
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, h.maxUploadBytes)
fileHeader, err := c.FormFile("file")
if err != nil {
var maxBytesError *http.MaxBytesError
if errors.As(err, &maxBytesError) {
httpx.Error(c, http.StatusRequestEntityTooLarge, "payload_too_large", "上传内容超过大小限制")
return
}
httpx.Error(c, http.StatusBadRequest, "invalid_request", "请选择要上传的文件")
return
}
file, err := fileHeader.Open()
if err != nil {
httpx.Error(c, http.StatusBadRequest, "invalid_request", "文件读取失败")
return
}
defer file.Close()
// 先由文件服务保存内容再用其返回的相对路径创建资料记录handler 不拼接任何本地路径。
stored, err := h.service.Save(project.Identity, fileHeader.Filename, file)
if err != nil {
httpx.Error(c, http.StatusInternalServerError, "internal_error", "文件保存失败")
return
}
source, err := h.service.CreateSource(userID, project, c.PostForm("title"), stored)
if err != nil {
if cleanupErr := h.service.Remove(stored); cleanupErr != nil {
// 清理错误仅写服务端日志,响应继续使用稳定中文错误,不暴露 storage root。
log.Printf("source file cleanup failed: %v", cleanupErr)
}
writeSourceError(c, err)
return
}
c.JSON(http.StatusCreated, sourceDTO(*source))
}
func sourceDTO(source models.SaSource) SourceDTO {
return SourceDTO{
ID: source.Identity, ProjectID: source.ProjectIdentity, Kind: source.Kind,
Title: source.Title, StorageKey: filepath.Base(filepath.FromSlash(source.FilePath)),
CreatedAt: source.CreatedAt.UTC(), UpdatedAt: source.UpdatedAt.UTC(),
}
}
func writeSourceError(c *gin.Context, err error) {
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
httpx.Error(c, http.StatusNotFound, "not_found", "项目不存在")
case errors.Is(err, ErrSourceTitleRequired), errors.Is(err, ErrSourcePathRequired):
httpx.Error(c, http.StatusBadRequest, "invalid_request", "资料参数无效")
default:
httpx.Error(c, http.StatusInternalServerError, "internal_error", "资料创建失败")
}
}

View File

@@ -1,203 +0,0 @@
package files
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"log"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gin-gonic/gin"
"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 TestFileRegistrarSavesBeforeCreatingIdentitySourceDTO(t *testing.T) {
router, database, project, storageRoot := newFileHandlerTestRouter(t, 1, 1)
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
require.NoError(t, writer.WriteField("title", "客户资料"))
part, err := writer.CreateFormFile("file", "客户资料.txt")
require.NoError(t, err)
_, err = part.Write([]byte("private content"))
require.NoError(t, err)
require.NoError(t, writer.Close())
req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+project.Identity+"/sources", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Authorization", "Bearer test-token")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusCreated, rec.Code, rec.Body.String())
var source models.SaSource
require.NoError(t, database.Where("project_id = ?", project.ID).First(&source).Error)
require.FileExists(t, filepath.Join(storageRoot, filepath.FromSlash(source.FilePath)))
require.Contains(t, filepath.ToSlash(source.FilePath), "projects/"+project.Identity+"/")
require.NotContains(t, filepath.ToSlash(source.FilePath), fmt.Sprintf("projects/%d/", project.ID))
var payload map[string]any
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
require.Equal(t, source.Identity, payload["id"])
require.Equal(t, project.Identity, payload["projectId"])
require.Equal(t, filepath.Base(source.FilePath), payload["storageKey"])
require.NotContains(t, payload, "filePath")
require.NotContains(t, payload, "AbsolutePath")
require.NotContains(t, rec.Body.String(), storageRoot)
require.NotContains(t, rec.Body.String(), fmt.Sprintf("projects/%d/", project.ID))
}
func TestFileRegistrarChecksOwnershipBeforeWritingFile(t *testing.T) {
router, database, project, storageRoot := newFileHandlerTestRouter(t, 1, 2)
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", "hidden.txt")
require.NoError(t, err)
_, err = part.Write([]byte("must not be stored"))
require.NoError(t, err)
require.NoError(t, writer.Close())
req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+project.Identity+"/sources", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Authorization", "Bearer test-token")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusNotFound, rec.Code, rec.Body.String())
var count int64
require.NoError(t, database.Model(&models.SaSource{}).Count(&count).Error)
require.Zero(t, count)
entries, err := os.ReadDir(storageRoot)
require.NoError(t, err)
require.Empty(t, entries)
}
func TestFileRegistrarRemovesStoredFileWhenSourceCreateFails(t *testing.T) {
router, database, project, storageRoot := newFileHandlerTestRouter(t, 1, 1)
require.NoError(t, database.Callback().Create().Before("gorm:create").Register("test:fail_source_create", func(tx *gorm.DB) {
if tx.Statement.Schema != nil && tx.Statement.Schema.Table == (models.SaSource{}).TableName() {
tx.AddError(errors.New("forced source create failure"))
}
}))
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", "orphan.txt")
require.NoError(t, err)
_, err = part.Write([]byte("must be compensated"))
require.NoError(t, err)
require.NoError(t, writer.Close())
req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+project.Identity+"/sources", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Authorization", "Bearer test-token")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusInternalServerError, rec.Code, rec.Body.String())
require.NotContains(t, rec.Body.String(), storageRoot)
entries, err := os.ReadDir(storageRoot)
require.NoError(t, err)
require.Empty(t, entries, "source 元数据失败后存储目录应为空")
}
func TestFileRegistrarRejectsRequestOverConfiguredUploadLimit(t *testing.T) {
router, _, project, storageRoot := newFileHandlerTestRouterWithLimit(t, 1, 1, 256)
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", "oversize.bin")
require.NoError(t, err)
_, err = part.Write(bytes.Repeat([]byte("x"), 1024))
require.NoError(t, err)
require.NoError(t, writer.Close())
req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+project.Identity+"/sources", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Authorization", "Bearer test-token")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusRequestEntityTooLarge, rec.Code, rec.Body.String())
var payload httpx.ErrorEnvelope
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
require.Equal(t, "payload_too_large", payload.Error.Code)
require.Equal(t, "上传内容超过大小限制", payload.Error.Message)
entries, err := os.ReadDir(storageRoot)
require.NoError(t, err)
require.Empty(t, entries)
}
func TestFileRegistrarLogsCleanupFailureWithoutLeakingStorageRoot(t *testing.T) {
router, database, project, storageRoot, service := newFileHandlerTestRouterWithService(t, 1, 1, DefaultMaxUploadBytes)
require.NoError(t, database.Callback().Create().Before("gorm:create").Register("test:fail_source_create_for_cleanup_log", func(tx *gorm.DB) {
if tx.Statement.Schema != nil && tx.Statement.Schema.Table == (models.SaSource{}).TableName() {
tx.AddError(errors.New("forced source create failure"))
}
}))
service.remove = func(path string) error {
if strings.HasPrefix(filepath.Base(path), ".upload-") {
return os.Remove(path)
}
return fmt.Errorf("cleanup blocked for %s", path)
}
var serverLog bytes.Buffer
previousLogOutput := log.Writer()
log.SetOutput(&serverLog)
t.Cleanup(func() { log.SetOutput(previousLogOutput) })
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", "cleanup-failure.txt")
require.NoError(t, err)
_, err = part.Write([]byte("content"))
require.NoError(t, err)
require.NoError(t, writer.Close())
req := httptest.NewRequest(http.MethodPost, "/api/v1/projects/"+project.Identity+"/sources", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Authorization", "Bearer test-token")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusInternalServerError, rec.Code, rec.Body.String())
require.NotContains(t, rec.Body.String(), storageRoot)
require.Contains(t, serverLog.String(), "source file cleanup failed")
require.Contains(t, serverLog.String(), storageRoot)
}
func newFileHandlerTestRouter(t *testing.T, currentUserID, ownerID uint) (*gin.Engine, *gorm.DB, models.SaProject, string) {
return newFileHandlerTestRouterWithLimit(t, currentUserID, ownerID, DefaultMaxUploadBytes)
}
func newFileHandlerTestRouterWithLimit(t *testing.T, currentUserID, ownerID uint, maxUploadBytes int64) (*gin.Engine, *gorm.DB, models.SaProject, string) {
router, database, project, storageRoot, _ := newFileHandlerTestRouterWithService(t, currentUserID, ownerID, maxUploadBytes)
return router, database, project, storageRoot
}
func newFileHandlerTestRouterWithService(t *testing.T, currentUserID, ownerID uint, maxUploadBytes int64) (*gin.Engine, *gorm.DB, models.SaProject, string, *Service) {
t.Helper()
database, err := gorm.Open(sqlite.Open(fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())), &gorm.Config{TranslateError: true})
require.NoError(t, err)
require.NoError(t, models.AutoMigrate(database))
models.DBService = database
require.NoError(t, database.Create(&models.SaUser{Email: "owner@example.com", DisplayName: "Owner", PasswordHash: "hash"}).Error)
require.NoError(t, database.Create(&models.SaUser{Email: "other@example.com", DisplayName: "Other", PasswordHash: "hash"}).Error)
project := models.SaProject{OwnerID: ownerID, Name: "Files", Identifier: fmt.Sprintf("FILES-%d", ownerID)}
require.NoError(t, database.Create(&project).Error)
storageRoot := t.TempDir()
service := NewService(storageRoot, database)
router := httpx.NewProtectedRouter(
config.Config{Env: "test"},
func(string) (uint, error) { return currentUserID, nil },
NewHandler(service, maxUploadBytes),
)
return router, database, project, storageRoot, service
}

View File

@@ -1,216 +0,0 @@
package files
import (
"crypto/rand"
"encoding/hex"
"errors"
"io"
"os"
"path/filepath"
"strings"
"gorm.io/gorm"
"senlinai-agent/backend/internal/models"
)
type Service struct {
root string
db *gorm.DB
createTemp func(string, string) (stagedFile, error)
publish func(string, string) error
remove func(string) error
newStorageKey func() (string, error)
}
type stagedFile interface {
io.Writer
Close() error
Name() string
}
type StoredFile struct {
OriginalName string
StorageKey string
RelativePath string
AbsolutePath string
}
// NewService 集中持有存储根目录;任何服务端本地路径都只能由该服务构造。
func NewService(root string, databases ...*gorm.DB) *Service {
var database *gorm.DB
if len(databases) > 0 {
database = databases[0]
}
return &Service{
root: root, db: database,
createTemp: func(directory, pattern string) (stagedFile, error) { return os.CreateTemp(directory, pattern) },
publish: os.Link,
remove: os.Remove,
newStorageKey: randomStorageKey,
}
}
// Save 使用项目公开 identity 和随机 opaque key 定位文件;硬链接发布提供跨请求的排他创建语义。
func (s *Service) Save(projectIdentity string, originalName string, content io.Reader) (StoredFile, error) {
cleanName := filepath.Base(strings.ReplaceAll(strings.TrimSpace(originalName), "\\", "/"))
if cleanName == "." || cleanName == "" {
cleanName = "upload.bin"
}
projectSegment := filepath.Base(strings.ReplaceAll(strings.TrimSpace(projectIdentity), "\\", "/"))
if projectSegment == "." || projectSegment == "" || projectSegment != strings.TrimSpace(projectIdentity) {
return StoredFile{}, ErrSourcePathRequired
}
directoryRelative := filepath.ToSlash(filepath.Join("projects", projectSegment))
directoryAbsolute, err := s.absolutePath(directoryRelative)
if err != nil {
return StoredFile{}, err
}
if err := os.MkdirAll(directoryAbsolute, 0o755); err != nil {
return StoredFile{}, err
}
// 临时文件与最终文件位于同一目录,完整关闭后再排他发布,避免暴露半写入内容。
file, err := s.createTemp(directoryAbsolute, ".upload-*")
if err != nil {
return StoredFile{}, err
}
if _, err := io.Copy(file, content); err != nil {
_ = file.Close()
s.cleanupOwnedFiles(file.Name())
return StoredFile{}, err
}
if err := file.Close(); err != nil {
s.cleanupOwnedFiles(file.Name())
return StoredFile{}, err
}
for attempt := 0; attempt < storageKeyAttempts; attempt++ {
storageKey, err := s.newStorageKey()
if err != nil {
s.cleanupOwnedFiles(file.Name())
return StoredFile{}, err
}
relative := filepath.ToSlash(filepath.Join(directoryRelative, storageKey))
absolute, err := s.absolutePath(relative)
if err != nil {
s.cleanupOwnedFiles(file.Name())
return StoredFile{}, err
}
if err := s.publish(file.Name(), absolute); err != nil {
if errors.Is(err, os.ErrExist) {
continue
}
s.cleanupOwnedFiles(file.Name())
return StoredFile{}, err
}
if err := s.remove(file.Name()); err != nil {
// final path 由本次排他发布创建,因此这里只清理本次请求拥有的两个路径。
s.cleanupOwnedFiles(file.Name(), absolute)
return StoredFile{}, err
}
return StoredFile{OriginalName: cleanName, StorageKey: storageKey, RelativePath: relative, AbsolutePath: absolute}, nil
}
s.cleanupOwnedFiles(file.Name())
return StoredFile{}, ErrStorageKeyCollision
}
// Remove 只依据 Service 生成的相对路径定位文件,并尽量移除直至存储根目录的空父目录。
func (s *Service) Remove(stored StoredFile) error {
absolute, err := s.absolutePath(stored.RelativePath)
if err != nil {
return err
}
if err := s.remove(absolute); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
root, err := filepath.Abs(s.root)
if err != nil {
return err
}
for directory := filepath.Dir(absolute); directory != root; directory = filepath.Dir(directory) {
entries, err := os.ReadDir(directory)
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil {
return err
}
if len(entries) > 0 {
return nil
}
if err := s.remove(directory); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
}
return nil
}
func (s *Service) absolutePath(relativePath string) (string, error) {
cleaned := filepath.Clean(filepath.FromSlash(strings.TrimSpace(relativePath)))
if cleaned == "." || filepath.IsAbs(cleaned) || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) {
return "", ErrSourcePathRequired
}
root, err := filepath.Abs(s.root)
if err != nil {
return "", err
}
absolute, err := filepath.Abs(filepath.Join(root, cleaned))
if err != nil {
return "", err
}
relativeToRoot, err := filepath.Rel(root, absolute)
if err != nil || relativeToRoot == ".." || strings.HasPrefix(relativeToRoot, ".."+string(filepath.Separator)) {
return "", ErrSourcePathRequired
}
return absolute, nil
}
func (s *Service) cleanupOwnedFiles(paths ...string) {
for _, path := range paths {
_ = s.remove(path)
}
}
func (s *Service) database() *gorm.DB {
if s.db != nil {
return s.db
}
return models.DBService
}
var (
ErrSourceTitleRequired = errors.New("source title is required")
ErrSourcePathRequired = errors.New("source file path is required")
ErrStorageKeyCollision = errors.New("unable to allocate unique storage key")
)
const storageKeyAttempts = 8
func randomStorageKey() (string, error) {
buffer := make([]byte, 16)
if _, err := rand.Read(buffer); err != nil {
return "", err
}
return hex.EncodeToString(buffer), nil
}
// CreateSource 只持久化 Save 产生的相对路径,不接受 handler 自行拼接本地路径。
func (s *Service) CreateSource(ownerID uint, project *models.SaProject, title string, stored StoredFile) (*models.SaSource, error) {
title = strings.TrimSpace(title)
if title == "" {
title = stored.OriginalName
}
if title == "" {
return nil, ErrSourceTitleRequired
}
relativePath := filepath.ToSlash(strings.TrimSpace(stored.RelativePath))
if relativePath == "" || filepath.IsAbs(relativePath) {
return nil, ErrSourcePathRequired
}
source := &models.SaSource{
ProjectID: project.ID, ProjectIdentity: project.Identity, CreatedBy: ownerID,
Kind: "file", Title: title, FilePath: relativePath,
}
if err := s.database().Create(source).Error; err != nil {
return nil, err
}
return source, nil
}

View File

@@ -1,187 +0,0 @@
package files
import (
"errors"
"io/fs"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/stretchr/testify/require"
)
type failingReader struct {
sent bool
}
func (r *failingReader) Read(buffer []byte) (int, error) {
if !r.sent {
r.sent = true
return copy(buffer, "partial"), nil
}
return 0, errors.New("copy failed")
}
type closeFailingFile struct {
*os.File
}
func (f *closeFailingFile) Close() error {
_ = f.File.Close()
return errors.New("close failed")
}
func TestSaveStoresFileUnderProjectIdentityWithOpaqueKey(t *testing.T) {
service := NewService(t.TempDir())
projectIdentity := "019b0000-0000-7000-8000-000000000012"
stored, err := service.Save(projectIdentity, "brief.md", strings.NewReader("hello"))
require.NoError(t, err)
require.Equal(t, "brief.md", stored.OriginalName)
require.Contains(t, stored.RelativePath, "projects/"+projectIdentity+"/")
require.NotContains(t, stored.RelativePath, "projects/12/")
require.Regexp(t, `^[a-f0-9]{32}$`, stored.StorageKey)
require.Equal(t, stored.StorageKey, filepath.Base(stored.RelativePath))
require.FileExists(t, stored.AbsolutePath)
}
func TestSaveNeutralizesPathTraversal(t *testing.T) {
service := NewService(t.TempDir())
stored, err := service.Save("019b0000-0000-7000-8000-000000000012", "..\\..\\secret.txt", strings.NewReader("hello"))
require.NoError(t, err)
require.Equal(t, "secret.txt", stored.OriginalName)
require.NotContains(t, stored.RelativePath, "secret.txt")
}
func TestSaveRemovesTemporaryAndPartialFilesWhenCopyFails(t *testing.T) {
storageRoot := t.TempDir()
service := NewService(storageRoot)
_, err := service.Save("019b0000-0000-7000-8000-000000000012", "broken.bin", &failingReader{})
require.ErrorContains(t, err, "copy failed")
requireNoStoredFiles(t, storageRoot)
}
func TestSaveRemovesTemporaryFileWhenCloseFails(t *testing.T) {
storageRoot := t.TempDir()
service := NewService(storageRoot)
service.createTemp = func(directory, pattern string) (stagedFile, error) {
file, err := os.CreateTemp(directory, pattern)
if err != nil {
return nil, err
}
return &closeFailingFile{File: file}, nil
}
_, err := service.Save("019b0000-0000-7000-8000-000000000012", "broken.bin", strings.NewReader("content"))
require.ErrorContains(t, err, "close failed")
requireNoStoredFiles(t, storageRoot)
}
func TestSaveCollisionNeverOverwritesOrDeletesExistingFile(t *testing.T) {
storageRoot := t.TempDir()
service := NewService(storageRoot)
service.newStorageKey = func() (string, error) { return "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", nil }
projectIdentity := "019b0000-0000-7000-8000-000000000012"
first, err := service.Save(projectIdentity, "first.bin", strings.NewReader("first owner"))
require.NoError(t, err)
_, err = service.Save(projectIdentity, "second.bin", strings.NewReader("second owner"))
require.ErrorIs(t, err, ErrStorageKeyCollision)
content, readErr := os.ReadFile(first.AbsolutePath)
require.NoError(t, readErr)
require.Equal(t, "first owner", string(content))
requireOnlyStoredFile(t, storageRoot, first.AbsolutePath)
}
func TestConcurrentSaveWithSameKeyPublishesExactlyOneOwner(t *testing.T) {
storageRoot := t.TempDir()
service := NewService(storageRoot)
service.newStorageKey = func() (string, error) { return "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", nil }
projectIdentity := "019b0000-0000-7000-8000-000000000012"
start := make(chan struct{})
type result struct {
stored StoredFile
err error
}
results := make(chan result, 2)
var ready sync.WaitGroup
ready.Add(2)
for _, body := range []string{"alpha", "beta"} {
go func(content string) {
ready.Done()
<-start
stored, err := service.Save(projectIdentity, content+".bin", strings.NewReader(content))
results <- result{stored: stored, err: err}
}(body)
}
ready.Wait()
close(start)
firstResult, secondResult := <-results, <-results
errors := []error{firstResult.err, secondResult.err}
require.Equal(t, 1, countNilErrors(errors))
require.Equal(t, 1, countMatchingErrors(errors, ErrStorageKeyCollision))
winner := firstResult.stored
if firstResult.err != nil {
winner = secondResult.stored
}
require.FileExists(t, winner.AbsolutePath)
requireOnlyStoredFile(t, storageRoot, winner.AbsolutePath)
}
func requireNoStoredFiles(t *testing.T, storageRoot string) {
t.Helper()
require.NoError(t, filepath.WalkDir(storageRoot, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if path != storageRoot && !entry.IsDir() {
t.Fatalf("unexpected stored file after failure: %s", path)
}
return nil
}))
}
func requireOnlyStoredFile(t *testing.T, storageRoot, expectedPath string) {
t.Helper()
files := []string{}
require.NoError(t, filepath.WalkDir(storageRoot, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return err
}
if !entry.IsDir() {
files = append(files, path)
}
return nil
}))
require.Equal(t, []string{expectedPath}, files)
}
func countNilErrors(values []error) int {
count := 0
for _, err := range values {
if err == nil {
count++
}
}
return count
}
func countMatchingErrors(values []error, target error) int {
count := 0
for _, err := range values {
if errors.Is(err, target) {
count++
}
}
return count
}

View File

@@ -59,8 +59,8 @@ func (a StaticAnalyzer) Analyze(item models.SaInboxItem, userID uint) ([]Suggest
// 默认分析器只把用户主动收集的内容整理成可审阅草稿;它不调用 provider也不创建正式对象。
return []Suggestion{
{Kind: "task", Title: "跟进:" + subject, Body: body},
{Kind: "note", Title: subject + "整理笔记", Body: body},
{Kind: "source", Title: subject + "背景资料", Body: body},
{Kind: "document", Title: subject + "整理笔记.md", Body: body},
{Kind: "document", Title: subject + "背景资料.md", Body: body},
}, nil
}
@@ -222,7 +222,7 @@ func normalizeSuggestions(suggestions []Suggestion) ([]Suggestion, error) {
for _, suggestion := range suggestions {
kind := strings.ToLower(strings.TrimSpace(suggestion.Kind))
title := strings.TrimSpace(suggestion.Title)
if title == "" || (kind != "task" && kind != "note" && kind != "source") {
if title == "" || (kind != "task" && kind != "document") {
return nil, ErrInvalidSuggestion
}
result = append(result, Suggestion{Kind: kind, Title: title, Body: strings.TrimSpace(suggestion.Body)})
@@ -274,17 +274,21 @@ func createConfirmedObject(tx *gorm.DB, item models.SaInboxItem, suggestion mode
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.SaNote{
case "document":
name := strings.TrimSpace(suggestion.Title)
if !strings.HasSuffix(strings.ToLower(name), ".md") {
name += ".md"
}
document := models.SaDocument{
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.SaSource{
ProjectID: item.ProjectID, ProjectIdentity: item.ProjectIdentity, CreatedBy: item.CreatedBy,
SourceInboxItemID: &sourceInboxItemID, Kind: "text", Title: suggestion.Title, ContentText: suggestion.Body,
}).Error
SourceInboxItemID: &sourceInboxItemID, Kind: models.DocumentKindFile, Name: name,
NormalizedName: strings.ToLower(name), Extension: ".md", MimeType: "text/markdown; charset=utf-8",
Size: int64(len([]byte(suggestion.Body))), Revision: 1, ScanStatus: "not_scanned",
}
if err := tx.Create(&document).Error; err != nil {
return err
}
return tx.Create(&models.SaDocumentContent{DocumentID: document.ID, Markdown: suggestion.Body}).Error
default:
return ErrInvalidSuggestion
}

View File

@@ -44,7 +44,7 @@ func TestStaticAnalyzerBuildsReviewableDraftsFromInboxContent(t *testing.T) {
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})
require.Equal(t, []string{"task", "document", "document"}, []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)
@@ -126,8 +126,8 @@ func TestConfirmCreatesOnlySelectedSavedSuggestionsWithInboxIdentity(t *testing.
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: "document", Title: "会议纪要.md", Body: "保留讨论结论"},
{Kind: "document", Title: "背景资料.md", Body: "这是一段收集内容,不是上传文件"},
{Kind: "task", Title: "不创建的任务", Body: "未被勾选"},
}})
analysisResponse := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/analyze", nil)
@@ -155,22 +155,19 @@ func TestConfirmCreatesOnlySelectedSavedSuggestionsWithInboxIdentity(t *testing.
require.Equal(t, fixture.owner.ID, task.CreatedBy)
requireSourceInboxIdentity(t, item, task.SourceInboxItemID, task.SourceInboxItemIdentity)
var note models.SaNote
require.NoError(t, fixture.database.First(&note).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.SaSource
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 documents []models.SaDocument
require.NoError(t, fixture.database.Order("id asc").Find(&documents).Error)
require.Len(t, documents, 2)
require.Equal(t, "会议纪要.md", documents[0].Name)
require.Equal(t, "背景资料.md", documents[1].Name)
for _, document := range documents {
require.Equal(t, fixture.project.ID, document.ProjectID)
require.Equal(t, fixture.owner.ID, document.CreatedBy)
requireSourceInboxIdentity(t, item, document.SourceInboxItemID, document.SourceInboxItemIdentity)
}
var secondContent models.SaDocumentContent
require.NoError(t, fixture.database.Where("document_id = ?", documents[1].ID).First(&secondContent).Error)
require.Equal(t, "这是一段收集内容,不是上传文件", secondContent.Markdown)
var skipped int64
require.NoError(t, fixture.database.Model(&models.SaTask{}).Where("title = ?", "不创建的任务").Count(&skipped).Error)
@@ -227,14 +224,15 @@ func TestConfirmIsTransactionalWhenASelectedWriteFails(t *testing.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: "模拟持久化失败"},
{Kind: "document", Title: "失败资料.md", 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"
const callbackName = "test:fail_inbox_document_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 == "SaSource" {
document, ok := tx.Statement.Dest.(*models.SaDocument)
if ok && document.Name == "失败资料.md" {
tx.AddError(errors.New("simulated source write failure"))
}
}))
@@ -256,8 +254,8 @@ func TestRepeatedConfirmReturnsPersistedResultAfterRelatedObjectsChange(t *testi
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: "document", Title: "确认结果.md", Body: "原始确认创建两个对象"},
{Kind: "document", Title: "不能追加创建.md", 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, analysis.Suggestions[1].ID}}
@@ -268,11 +266,12 @@ func TestRepeatedConfirmReturnsPersistedResultAfterRelatedObjectsChange(t *testi
requireFormalObjectCounts(t, fixture.database, 1, 1, 0)
sourceInboxItemID := item.ID
extraSource := models.SaSource{
extraDocument := models.SaDocument{
ProjectID: item.ProjectID, CreatedBy: fixture.owner.ID, SourceInboxItemID: &sourceInboxItemID,
Kind: "text", Title: "后续关联资料", ContentText: "不得改变历史确认结果",
Kind: models.DocumentKindFile, Name: "后续关联资料.md", NormalizedName: "后续关联资料.md",
Extension: ".md", Revision: 1,
}
require.NoError(t, fixture.database.Create(&extraSource).Error)
require.NoError(t, fixture.database.Create(&extraDocument).Error)
second := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/confirm", body)
require.Equal(t, http.StatusOK, second.Code, second.Body.String())
requireConfirmCreatedCount(t, second, 2)
@@ -280,7 +279,7 @@ func TestRepeatedConfirmReturnsPersistedResultAfterRelatedObjectsChange(t *testi
var createdTask models.SaTask
require.NoError(t, fixture.database.Where("source_inbox_item_id = ?", item.ID).First(&createdTask).Error)
require.NoError(t, fixture.database.Delete(&createdTask).Error)
require.NoError(t, fixture.database.Delete(&extraSource).Error)
require.NoError(t, fixture.database.Delete(&extraDocument).Error)
differentSelection := performInboxJSON(t, router, http.MethodPost, "/api/v1/inbox/"+item.Identity+"/confirm", gin.H{
"suggestionIds": []string{analysis.Suggestions[2].ID},
})
@@ -349,8 +348,7 @@ func requireFormalObjectCounts(t *testing.T, database *gorm.DB, tasks, notes, so
want int64
}{
{model: &models.SaTask{}, want: tasks},
{model: &models.SaNote{}, want: notes},
{model: &models.SaSource{}, want: sources},
{model: &models.SaDocument{}, want: notes + sources},
} {
var count int64
require.NoError(t, database.Model(check.model).Count(&count).Error)

View File

@@ -1,24 +0,0 @@
package notes
import (
"errors"
"strings"
"senlinai-agent/backend/internal/models"
)
type Service struct {
}
func NewService() *Service {
return &Service{}
}
func (s *Service) CreateNote(projectID uint, userID uint, title string, markdown string) (*models.SaNote, error) {
title = strings.TrimSpace(title)
if title == "" {
return nil, errors.New("note title is required")
}
note := &models.SaNote{ProjectID: projectID, CreatedBy: userID, Title: title, Markdown: markdown}
return note, models.DBService.Create(note).Error
}

View File

@@ -50,7 +50,7 @@ type WorkspaceDTO struct {
Inbox []WorkspaceInboxDTO `json:"inbox"`
Tasks []WorkspaceTaskDTO `json:"tasks"`
AISessions []WorkspaceAISessionDTO `json:"aiSessions"`
NotesSources []WorkspaceNoteSourceDTO `json:"notesSources"`
Documents []WorkspaceDocumentDTO `json:"documents"`
CronPlans []WorkspaceCronPlanDTO `json:"cronPlans"`
}
@@ -121,15 +121,15 @@ type WorkspaceAISessionDTO struct {
References []string `json:"references"`
}
// WorkspaceNoteSourceDTO 将笔记和资料合并为统一的工作区列表项。
type WorkspaceNoteSourceDTO struct {
// WorkspaceDocumentDTO is the recent document summary used by the workspace.
type WorkspaceDocumentDTO struct {
ID string `json:"id"`
ProjectID string `json:"projectId"`
Kind string `json:"kind"`
Title string `json:"title"`
Name string `json:"name"`
Extension string `json:"extension"`
MimeType string `json:"mimeType"`
UpdatedAt time.Time `json:"updatedAt"`
Tag string `json:"tag"`
Source string `json:"source"`
}
// WorkspaceCronPlanDTO 是只展示元数据、不执行自主 Agent 的计划任务摘要。

View File

@@ -127,8 +127,8 @@ func TestWorkspaceMatchesFrontendContract(t *testing.T) {
require.NoError(t, database.Create(&models.SaTask{ProjectID: project.ID, CreatedBy: 1, Title: "Confirm IA", Description: "Review channel layout.", Status: "open", DueAt: &nextRun}).Error)
require.NoError(t, database.Create(&models.SaTask{ProjectID: project.ID, CreatedBy: 1, Title: "Archive notes", Description: "Move outdated notes.", Status: "done"}).Error)
require.NoError(t, database.Create(&models.SaAISession{ProjectID: project.ID, CreatedBy: 1, Title: "UI prototype critique", Context: "Discuss channel behavior."}).Error)
require.NoError(t, database.Create(&models.SaNote{ProjectID: project.ID, CreatedBy: 1, Title: "Workbench principles", Markdown: "# Notes"}).Error)
require.NoError(t, database.Create(&models.SaSource{ProjectID: project.ID, CreatedBy: 1, Kind: "link", Title: "Reference board", URL: "https://example.com"}).Error)
require.NoError(t, database.Create(&models.SaDocument{ProjectID: project.ID, CreatedBy: 1, Kind: models.DocumentKindFile, Name: "Workbench principles.md", NormalizedName: "workbench principles.md", Extension: ".md", Revision: 1}).Error)
require.NoError(t, database.Create(&models.SaDocument{ProjectID: project.ID, CreatedBy: 1, Kind: models.DocumentKindFile, Name: "Reference board.pdf", NormalizedName: "reference board.pdf", Extension: ".pdf", Revision: 1}).Error)
require.NoError(t, database.Create(&models.SaProjectChannel{ProjectID: project.ID, Title: "Roadmap Board", Icon: "link", URL: "https://example.com/roadmap-a1", SortOrder: 7}).Error)
require.NoError(t, database.Create(&models.SaProjectChannel{ProjectID: other.ID, Title: "Other Board", Icon: "link", URL: "https://example.com/other", SortOrder: 7}).Error)
require.NoError(t, database.Create(&models.SaCronPlan{ProjectID: project.ID, CreatedBy: 1, Title: "Weekly inbox review reminder", Schedule: "0 9 * * 1", NextRunAt: &nextRun, Enabled: true, LastResult: "Not run yet"}).Error)
@@ -158,9 +158,9 @@ func TestWorkspaceMatchesFrontendContract(t *testing.T) {
require.NotEmpty(t, workspace.Tasks[0].CreatedAt)
require.Len(t, workspace.AISessions, 1)
require.Len(t, workspace.RecentSessions, 1)
require.Len(t, workspace.NotesSources, 2)
require.Equal(t, "note", workspace.NotesSources[0].Kind)
require.Equal(t, "link", workspace.NotesSources[1].Kind)
require.Len(t, workspace.Documents, 2)
require.Equal(t, models.DocumentKindFile, workspace.Documents[0].Kind)
require.Equal(t, models.DocumentKindFile, workspace.Documents[1].Kind)
require.Len(t, workspace.CronPlans, 1)
require.True(t, workspace.CronPlans[0].Enabled)
require.Equal(t, "David", workspace.CronPlans[0].Owner)

View File

@@ -13,7 +13,7 @@ type workspaceCounts struct {
inbox int64
tasks int64
aiSessions int64
notesSources int64
documents int64
cronPlans int64
}
@@ -48,7 +48,7 @@ func (s *Service) Workspace(ownerID uint, projectIdentity string) (WorkspaceDTO,
if err != nil {
return WorkspaceDTO{}, err
}
notesSources, err := s.workspaceNotesSources(project.ID, project.Identity)
documents, err := s.workspaceDocuments(project.ID, project.Identity)
if err != nil {
return WorkspaceDTO{}, err
}
@@ -74,7 +74,7 @@ func (s *Service) Workspace(ownerID uint, projectIdentity string) (WorkspaceDTO,
Inbox: inboxItems,
Tasks: tasks,
AISessions: aiSessions,
NotesSources: notesSources,
Documents: documents,
CronPlans: cronPlans,
}, nil
}
@@ -90,15 +90,9 @@ func (s *Service) workspaceCounts(projectID uint) (workspaceCounts, error) {
if err := models.DBService.Model(&models.SaAISession{}).Where("project_id = ?", projectID).Count(&counts.aiSessions).Error; err != nil {
return counts, err
}
var noteCount int64
if err := models.DBService.Model(&models.SaNote{}).Where("project_id = ?", projectID).Count(&noteCount).Error; err != nil {
if err := models.DBService.Model(&models.SaDocument{}).Where("project_id = ?", projectID).Count(&counts.documents).Error; err != nil {
return counts, err
}
var sourceCount int64
if err := models.DBService.Model(&models.SaSource{}).Where("project_id = ?", projectID).Count(&sourceCount).Error; err != nil {
return counts, err
}
counts.notesSources = noteCount + sourceCount
if err := models.DBService.Model(&models.SaCronPlan{}).Where("project_id = ?", projectID).Count(&counts.cronPlans).Error; err != nil {
return counts, err
}
@@ -118,13 +112,13 @@ func (s *Service) workspaceTags(projectID uint) ([]WorkspaceTagDTO, error) {
}
func (s *Service) workspaceChannels(projectID uint, projectIdentity string, counts workspaceCounts) ([]WorkspaceChannelDTO, error) {
total := counts.inbox + counts.tasks + counts.aiSessions + counts.notesSources + counts.cronPlans
total := counts.inbox + counts.tasks + counts.aiSessions + counts.documents + counts.cronPlans
channels := []WorkspaceChannelDTO{
{ID: projectIdentity + ":overview", ProjectID: projectIdentity, Type: "overview", Title: "Overview", Icon: "home", Count: total, URL: "", SortOrder: 1},
{ID: projectIdentity + ":inbox", ProjectID: projectIdentity, Type: "inbox", Title: "Message Flow", Icon: "mail", Count: counts.inbox, URL: "", SortOrder: 2},
{ID: projectIdentity + ":tasks", ProjectID: projectIdentity, Type: "tasks", Title: "Work Plan", Icon: "list", Count: counts.tasks, URL: "", SortOrder: 3},
{ID: projectIdentity + ":ai", ProjectID: projectIdentity, Type: "ai_sessions", Title: "AI Sessions", Icon: "sparkles", Count: counts.aiSessions, URL: "", SortOrder: 4},
{ID: projectIdentity + ":notes", ProjectID: projectIdentity, Type: "notes_sources", Title: "Notes & Sources", Icon: "file", Count: counts.notesSources, URL: "", SortOrder: 5},
{ID: projectIdentity + ":documents", ProjectID: projectIdentity, Type: "documents", Title: "Documents", Icon: "file", Count: counts.documents, URL: "", SortOrder: 5},
{ID: projectIdentity + ":cron", ProjectID: projectIdentity, Type: "cron", Title: "Cron Plans", Icon: "clock", Count: counts.cronPlans, URL: "", SortOrder: 6},
}
var customChannels []models.SaProjectChannel
@@ -229,27 +223,16 @@ func (s *Service) workspaceAISessions(projectID uint, projectIdentity string) ([
return items, nil
}
func (s *Service) workspaceNotesSources(projectID uint, projectIdentity string) ([]WorkspaceNoteSourceDTO, error) {
var notes []models.SaNote
if err := models.DBService.Where("project_id = ?", projectID).Order("updated_at desc, id desc").Limit(50).Find(&notes).Error; err != nil {
func (s *Service) workspaceDocuments(projectID uint, projectIdentity string) ([]WorkspaceDocumentDTO, error) {
var documents []models.SaDocument
if err := models.DBService.Where("project_id = ?", projectID).Order("updated_at desc, id desc").Limit(50).Find(&documents).Error; err != nil {
return nil, err
}
items := make([]WorkspaceNoteSourceDTO, 0, len(notes))
for _, note := range notes {
items = append(items, WorkspaceNoteSourceDTO{
ID: note.Identity, ProjectID: projectIdentity, Kind: "note", Title: note.Title,
UpdatedAt: note.UpdatedAt.UTC(), Source: "Markdown",
})
}
var sources []models.SaSource
if err := models.DBService.Where("project_id = ?", projectID).Order("updated_at desc, id desc").Limit(50).Find(&sources).Error; err != nil {
return nil, err
}
for _, source := range sources {
items = append(items, WorkspaceNoteSourceDTO{
ID: source.Identity, ProjectID: projectIdentity, Kind: source.Kind, Title: source.Title,
UpdatedAt: source.UpdatedAt.UTC(), Source: sourceDisplayName(source),
items := make([]WorkspaceDocumentDTO, 0, len(documents))
for _, document := range documents {
items = append(items, WorkspaceDocumentDTO{
ID: document.Identity, ProjectID: projectIdentity, Kind: document.Kind, Name: document.Name,
Extension: document.Extension, MimeType: document.MimeType, UpdatedAt: document.UpdatedAt.UTC(),
})
}
return items, nil
@@ -283,17 +266,6 @@ func utcOptionalTime(value *time.Time) *time.Time {
return &utc
}
func sourceDisplayName(source models.SaSource) string {
switch source.Kind {
case "file":
return "Attachment"
case "link":
return "URL"
default:
return source.Kind
}
}
func userDisplayName(assigneeID *uint, createdBy uint) (string, error) {
userID := createdBy
if assigneeID != nil && *assigneeID != 0 {

View File

@@ -33,11 +33,11 @@ func TestWorkspaceUsesIdentitiesUTCTimesAndProjectScopedTags(t *testing.T) {
inbox := models.SaInboxItem{ProjectID: project.ID, CreatedBy: 1, SourceType: "Manual", Title: "Collect notes", Body: "Turn research into tasks.", Status: "open", CreatedAt: createdAt, UpdatedAt: updatedAt}
task := models.SaTask{ProjectID: project.ID, CreatedBy: 1, TagID: &projectTag.ID, Title: "Confirm IA", Description: "Review channel layout.", Status: "done", DueAt: &nextRun, CreatedAt: createdAt, UpdatedAt: updatedAt}
session := models.SaAISession{ProjectID: project.ID, CreatedBy: 1, Title: "UI critique", Context: "Discuss channel behavior.", CreatedAt: createdAt, UpdatedAt: updatedAt}
note := models.SaNote{ProjectID: project.ID, CreatedBy: 1, Title: "Workbench principles", Markdown: "# Notes", CreatedAt: createdAt, UpdatedAt: updatedAt}
source := models.SaSource{ProjectID: project.ID, CreatedBy: 1, Kind: "link", Title: "Reference board", URL: "https://example.com", CreatedAt: createdAt, UpdatedAt: updatedAt}
firstDocument := models.SaDocument{ProjectID: project.ID, CreatedBy: 1, Kind: models.DocumentKindFile, Name: "Workbench principles.md", NormalizedName: "workbench principles.md", Extension: ".md", MimeType: "text/markdown", Revision: 1, CreatedAt: createdAt, UpdatedAt: updatedAt}
secondDocument := models.SaDocument{ProjectID: project.ID, CreatedBy: 1, Kind: models.DocumentKindFile, Name: "Reference board.pdf", NormalizedName: "reference board.pdf", Extension: ".pdf", MimeType: "application/pdf", Revision: 1, CreatedAt: createdAt, UpdatedAt: updatedAt}
channel := models.SaProjectChannel{ProjectID: project.ID, Title: "Roadmap Board", Icon: "link", URL: "https://example.com/roadmap", SortOrder: 7, CreatedAt: createdAt, UpdatedAt: updatedAt}
plan := models.SaCronPlan{ProjectID: project.ID, CreatedBy: 1, Title: "Weekly review", Schedule: "0 9 * * 1", NextRunAt: &nextRun, Enabled: true, CreatedAt: createdAt, UpdatedAt: updatedAt}
for _, record := range []any{&inbox, &task, &session, &note, &source, &channel, &plan} {
for _, record := range []any{&inbox, &task, &session, &firstDocument, &secondDocument, &channel, &plan} {
require.NoError(t, database.Create(record).Error)
}
@@ -71,10 +71,10 @@ func TestWorkspaceUsesIdentitiesUTCTimesAndProjectScopedTags(t *testing.T) {
require.Equal(t, updatedAt.UTC(), workspace.AISessions[0].UpdatedAt)
require.Equal(t, session.Identity, workspace.RecentSessions[0].ID)
require.Len(t, workspace.NotesSources, 2)
require.Equal(t, note.Identity, workspace.NotesSources[0].ID)
require.Equal(t, source.Identity, workspace.NotesSources[1].ID)
for _, item := range workspace.NotesSources {
require.Len(t, workspace.Documents, 2)
require.Equal(t, secondDocument.Identity, workspace.Documents[0].ID)
require.Equal(t, firstDocument.Identity, workspace.Documents[1].ID)
for _, item := range workspace.Documents {
require.Equal(t, project.Identity, item.ProjectID)
require.Equal(t, updatedAt.UTC(), item.UpdatedAt)
}

View File

@@ -6,6 +6,7 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/require"
@@ -37,8 +38,7 @@ func TestSearchHandlerReturnsCamelCaseIdentityResults(t *testing.T) {
require.NoError(t, database.Create(&project).Error)
task := models.SaTask{ProjectID: project.ID, CreatedBy: owner.ID, Title: "回调任务", Description: "检查签名", Status: "open"}
require.NoError(t, database.Create(&task).Error)
note := models.SaNote{ProjectID: project.ID, CreatedBy: owner.ID, Title: "接口说明", Markdown: "回调验签流程"}
require.NoError(t, database.Create(&note).Error)
document := createSearchDocument(t, database, project.ID, owner.ID, "接口说明.md", "回调验签流程", "", time.Time{})
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")
@@ -65,8 +65,8 @@ func TestSearchHandlerReturnsCamelCaseIdentityResults(t *testing.T) {
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"])
require.Equal(t, document.Identity, byType["document"]["id"])
require.Equal(t, project.Identity, byType["document"]["projectId"])
}
func TestSearchHandlerOnlyReturnsObjectsVisibleToCurrentUser(t *testing.T) {
@@ -79,20 +79,17 @@ func TestSearchHandlerOnlyReturnsObjectsVisibleToCurrentUser(t *testing.T) {
require.NoError(t, database.Create(&assigned).Error)
privateTask := models.SaTask{ProjectID: project.ID, CreatedBy: owner.ID, Title: "检索词私有任务", Status: "open"}
require.NoError(t, database.Create(&privateTask).Error)
sharedNote := models.SaNote{ProjectID: project.ID, CreatedBy: owner.ID, Title: "检索词共享笔记", Markdown: "已显式分享"}
require.NoError(t, database.Create(&sharedNote).Error)
privateNote := models.SaNote{ProjectID: project.ID, CreatedBy: owner.ID, Title: "检索词私有笔记", Markdown: "不得泄漏"}
require.NoError(t, database.Create(&privateNote).Error)
sharedDocument := createSearchDocument(t, database, project.ID, owner.ID, "检索词共享文档.md", "已显式分享", "", time.Time{})
privateDocument := createSearchDocument(t, database, project.ID, owner.ID, "检索词私有文档.md", "不得泄漏", "", time.Time{})
otherProject := models.SaProject{OwnerID: owner.ID, Name: "其他项目", Identifier: "OTHER"}
require.NoError(t, database.Create(&otherProject).Error)
crossProjectNote := models.SaNote{ProjectID: otherProject.ID, CreatedBy: owner.ID, Title: "检索词跨项目笔记", Markdown: "伪造分享也不得泄漏"}
require.NoError(t, database.Create(&crossProjectNote).Error)
crossProjectDocument := createSearchDocument(t, database, otherProject.ID, owner.ID, "检索词跨项目文档.md", "伪造分享也不得泄漏", "", time.Time{})
require.NoError(t, database.Create(&models.SaTaskShare{
TaskID: assigned.ID, ObjectType: "note", ObjectID: sharedNote.ID,
TaskID: assigned.ID, ObjectType: "document", ObjectID: sharedDocument.ID,
}).Error)
// 即使历史数据绕过服务层写入跨项目分享,搜索也必须在读取边界再次校验项目一致性。
require.NoError(t, database.Create(&models.SaTaskShare{
TaskID: assigned.ID, ObjectType: "note", ObjectID: crossProjectNote.ID,
TaskID: assigned.ID, ObjectType: "document", ObjectID: crossProjectDocument.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)
@@ -111,13 +108,13 @@ func TestSearchHandlerOnlyReturnsObjectsVisibleToCurrentUser(t *testing.T) {
}
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})
require.ElementsMatch(t, []string{assigned.Identity, sharedDocument.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.Contains(t, []string{"task", "document"}, 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, privateDocument.Identity, item.ID)
require.NotEqual(t, crossProjectDocument.Identity, item.ID)
require.NotEqual(t, project.Identity, item.ID)
}
}

View File

@@ -92,31 +92,40 @@ func (s *Service) Search(userID uint, query string) ([]SearchResultDTO, error) {
})
}
var notes []models.SaNote
noteFilter := fmt.Sprintf(`(sa_projects.owner_id = ? OR EXISTS (
var documents []models.SaDocument
documentFilter := fmt.Sprintf(`(sa_projects.owner_id = ? OR EXISTS (
SELECT 1 FROM sa_task_shares
JOIN sa_tasks ON sa_tasks.id = sa_task_shares.task_id
WHERE sa_task_shares.object_type = 'note'
AND sa_task_shares.object_id = sa_notes.id
AND sa_tasks.project_id = sa_notes.project_id
WHERE sa_task_shares.object_type = 'document'
AND sa_task_shares.object_id = sa_documents.id
AND sa_tasks.project_id = sa_documents.project_id
AND sa_tasks.assignee_id = ?
)) AND (sa_notes.title %s ? ESCAPE '!' OR sa_notes.markdown %s ? ESCAPE '!')`, operator, operator)
if err := s.database().Select("sa_notes.*").Joins("JOIN sa_projects ON sa_projects.id = sa_notes.project_id").
Where(noteFilter, userID, userID, like, like).
Order("sa_notes.updated_at DESC").Order("sa_notes.identity ASC").
)) AND (sa_documents.name %s ? ESCAPE '!' OR EXISTS (
SELECT 1 FROM sa_document_contents
WHERE sa_document_contents.document_id = sa_documents.id
AND sa_document_contents.markdown %s ? ESCAPE '!'
))`, operator, operator)
if err := s.database().Select("sa_documents.*").Joins("JOIN sa_projects ON sa_projects.id = sa_documents.project_id").
Where(documentFilter, userID, userID, like, like).
Order("sa_documents.updated_at DESC").Order("sa_documents.identity ASC").
Limit(maxSearchResults).
Find(&notes).Error; err != nil {
Find(&documents).Error; err != nil {
return nil, err
}
noteResults := make([]rankedSearchResult, 0, len(notes))
for _, note := range notes {
noteResults = append(noteResults, rankedSearchResult{
result: SearchResultDTO{Type: "note", ID: note.Identity, ProjectID: note.ProjectIdentity, Title: note.Title, Snippet: note.Markdown},
updatedAt: note.UpdatedAt,
documentResults := make([]rankedSearchResult, 0, len(documents))
for _, document := range documents {
var content models.SaDocumentContent
_ = s.database().Where("document_id = ?", document.ID).First(&content).Error
documentResults = append(documentResults, rankedSearchResult{
result: SearchResultDTO{
Type: "document", ID: document.Identity, ProjectID: document.ProjectIdentity,
Title: document.Name, Snippet: content.Markdown,
},
updatedAt: document.UpdatedAt,
})
}
return stableFairLimit(projectResults, taskResults, noteResults), nil
return stableFairLimit(projectResults, taskResults, documentResults), nil
}
func stableFairLimit(buckets ...[]rankedSearchResult) []SearchResultDTO {

View File

@@ -21,7 +21,7 @@ func TestPostgresSearchMatchesChinesePartialKeywords(t *testing.T) {
project := models.SaProject{OwnerID: owner.ID, Name: "中文回调平台", Identifier: "PG-CALLBACK"}
require.NoError(t, database.Create(&project).Error)
require.NoError(t, database.Create(&models.SaTask{ProjectID: project.ID, CreatedBy: owner.ID, Title: "回调任务", Status: "open"}).Error)
require.NoError(t, database.Create(&models.SaNote{ProjectID: project.ID, CreatedBy: owner.ID, Title: "说明", Markdown: "回调验签流程"}).Error)
createPostgresSearchDocument(t, database, project, owner.ID, models.SaDocument{Name: "说明.md"}, "回调验签流程")
results, err := NewService(database).Search(owner.ID, "回调")
@@ -38,16 +38,15 @@ func TestPostgresSearchKeepsAssignmentAndExplicitShareBoundaries(t *testing.T) {
assigned := models.SaTask{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.SaTask{ProjectID: project.ID, CreatedBy: owner.ID, Title: "边界词私有任务", Status: "open"}).Error)
sharedNote := models.SaNote{ProjectID: project.ID, CreatedBy: owner.ID, Title: "边界词共享笔记"}
require.NoError(t, database.Create(&sharedNote).Error)
require.NoError(t, database.Create(&models.SaNote{ProjectID: project.ID, CreatedBy: owner.ID, Title: "边界词私有笔记"}).Error)
require.NoError(t, database.Create(&models.SaTaskShare{TaskID: assigned.ID, ObjectType: "note", ObjectID: sharedNote.ID}).Error)
sharedDocument := createPostgresSearchDocument(t, database, project, owner.ID, models.SaDocument{Name: "边界词共享笔记.md"}, "")
createPostgresSearchDocument(t, database, project, owner.ID, models.SaDocument{Name: "边界词私有笔记.md"}, "")
require.NoError(t, database.Create(&models.SaTaskShare{TaskID: assigned.ID, ObjectType: "document", ObjectID: sharedDocument.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})
require.ElementsMatch(t, []string{assigned.Identity, sharedDocument.Identity}, []string{results[0].ID, results[1].ID})
}
func TestPostgresSearchUsesStableFairLimitAndRuneBoundedSnippets(t *testing.T) {
@@ -71,14 +70,11 @@ func TestPostgresSearchUsesStableFairLimitAndRuneBoundedSnippets(t *testing.T) {
Status: "open",
UpdatedAt: baseTime.Add(time.Duration(index) * time.Minute),
}).Error)
require.NoError(t, database.Create(&models.SaNote{
createPostgresSearchDocument(t, database, project, owner.ID, models.SaDocument{
Identity: fmt.Sprintf("00000000-0000-7003-8000-%012d", index),
ProjectID: project.ID,
CreatedBy: owner.ID,
Title: fmt.Sprintf("稳定排序笔记 %02d", index),
Markdown: strings.Repeat("森", 300) + "尾",
Name: fmt.Sprintf("稳定排序笔记 %02d.md", index),
UpdatedAt: baseTime.Add(time.Duration(index) * time.Minute),
}).Error)
}, strings.Repeat("森", 300)+"尾")
}
service := NewService(database)
@@ -86,7 +82,7 @@ func TestPostgresSearchUsesStableFairLimitAndRuneBoundedSnippets(t *testing.T) {
require.NoError(t, err)
require.Len(t, results, maxSearchResults)
require.Equal(t,
[]string{"project", "task", "note", "project", "task", "note", "project", "task", "note"},
[]string{"project", "task", "document", "project", "task", "document", "project", "task", "document"},
resultTypes(results[:9]),
)
require.Equal(t, "00000000-0000-7001-8000-000000000059", results[0].ID)
@@ -96,17 +92,39 @@ func TestPostgresSearchUsesStableFairLimitAndRuneBoundedSnippets(t *testing.T) {
for _, result := range results {
counts[result.Type]++
}
require.Equal(t, map[string]int{"project": 17, "task": 17, "note": 16}, counts)
require.Equal(t, map[string]int{"project": 17, "task": 17, "document": 16}, counts)
again, err := service.Search(owner.ID, "稳定排序")
require.NoError(t, err)
require.Equal(t, results, again)
noteResults, err := service.Search(owner.ID, "森")
documentResults, err := service.Search(owner.ID, "森")
require.NoError(t, err)
require.NotEmpty(t, noteResults)
require.Len(t, []rune(noteResults[0].Snippet), 240)
require.NotContains(t, noteResults[0].Snippet, "尾")
require.NotEmpty(t, documentResults)
require.Len(t, []rune(documentResults[0].Snippet), 240)
require.NotContains(t, documentResults[0].Snippet, "尾")
}
func createPostgresSearchDocument(
t *testing.T,
database *gorm.DB,
project models.SaProject,
ownerID uint,
document models.SaDocument,
markdown string,
) models.SaDocument {
t.Helper()
document.ProjectID = project.ID
document.ProjectIdentity = project.Identity
document.CreatedBy = ownerID
document.Kind = models.DocumentKindFile
document.Extension = ".md"
document.MimeType = "text/markdown; charset=utf-8"
document.NormalizedName = strings.ToLower(document.Name)
document.Revision = 1
require.NoError(t, database.Create(&document).Error)
require.NoError(t, database.Create(&models.SaDocumentContent{DocumentID: document.ID, Markdown: markdown}).Error)
return document
}
func newPostgresSearchTestDB(t *testing.T) *gorm.DB {

View File

@@ -12,20 +12,20 @@ import (
"senlinai-agent/backend/internal/models"
)
func TestSearchFindsNoteBody(t *testing.T) {
func TestSearchFindsDocumentBody(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.SaProject{ID: 1, OwnerID: 7, Name: "支付项目"}).Error)
require.NoError(t, database.Create(&models.SaNote{ProjectID: 1, CreatedBy: 7, Title: "接口方案", Markdown: "二维码支付回调设计"}).Error)
createSearchDocument(t, database, 1, 7, "接口方案.md", "二维码支付回调设计", "", time.Time{})
service := NewService()
results, err := service.Search(7, "回调")
require.NoError(t, err)
require.Len(t, results, 1)
require.Equal(t, "note", results[0].Type)
require.Equal(t, "document", results[0].Type)
}
func TestSearchAppliesStableFairGlobalLimitAcrossResultTypes(t *testing.T) {
@@ -52,13 +52,18 @@ func TestSearchAppliesStableFairGlobalLimitAcrossResultTypes(t *testing.T) {
Status: "open",
UpdatedAt: baseTime.Add(time.Duration(index) * time.Minute),
}).Error)
require.NoError(t, database.Create(&models.SaNote{
document := models.SaDocument{
Identity: fmt.Sprintf("00000000-0000-7003-8000-%012d", index),
ProjectID: project.ID,
CreatedBy: 7,
Title: fmt.Sprintf("稳定排序笔记 %02d", index),
Kind: models.DocumentKindFile,
Name: fmt.Sprintf("稳定排序文档 %02d.md", index),
NormalizedName: fmt.Sprintf("稳定排序文档 %02d.md", index),
Extension: ".md",
Revision: 1,
UpdatedAt: baseTime.Add(time.Duration(index) * time.Minute),
}).Error)
}
require.NoError(t, database.Create(&document).Error)
}
service := NewService(database)
@@ -70,9 +75,9 @@ func TestSearchAppliesStableFairGlobalLimitAcrossResultTypes(t *testing.T) {
for _, result := range results {
counts[result.Type]++
}
require.Equal(t, map[string]int{"project": 17, "task": 17, "note": 16}, counts)
require.Equal(t, map[string]int{"project": 17, "task": 17, "document": 16}, counts)
require.Equal(t,
[]string{"project", "task", "note", "project", "task", "note", "project", "task", "note"},
[]string{"project", "task", "document", "project", "task", "document", "project", "task", "document"},
resultTypes(results[:9]),
)
require.Equal(t, "00000000-0000-7001-8000-000000000059", results[0].ID)
@@ -91,12 +96,7 @@ func TestSearchBoundsChineseSnippetsByRune(t *testing.T) {
models.DBService = database
project := models.SaProject{OwnerID: 7, Name: "摘要项目", Identifier: "SNIPPET"}
require.NoError(t, database.Create(&project).Error)
require.NoError(t, database.Create(&models.SaNote{
ProjectID: project.ID,
CreatedBy: 7,
Title: "中文摘要",
Markdown: strings.Repeat("森", 300) + "尾",
}).Error)
createSearchDocument(t, database, project.ID, 7, "中文摘要.md", strings.Repeat("森", 300)+"尾", "", time.Time{})
results, err := NewService(database).Search(7, "森")
require.NoError(t, err)
@@ -113,15 +113,14 @@ func resultTypes(results []SearchResultDTO) []string {
return types
}
func TestSearchOnlyReturnsNavigableProjectTaskAndNoteResults(t *testing.T) {
func TestSearchOnlyReturnsNavigableProjectTaskAndDocumentResults(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.SaProject{ID: 1, OwnerID: 7, Name: "webhook 支付项目"}).Error)
require.NoError(t, database.Create(&models.SaTask{ProjectID: 1, CreatedBy: 7, Title: "回调任务", Description: "检查 webhook"}).Error)
require.NoError(t, database.Create(&models.SaNote{ProjectID: 1, CreatedBy: 7, Title: "回调笔记", Markdown: "webhook 流程"}).Error)
require.NoError(t, database.Create(&models.SaSource{ProjectID: 1, CreatedBy: 7, Kind: "link", Title: "支付文档", URL: "https://example.com/pay", ContentText: "webhook 签名"}).Error)
createSearchDocument(t, database, 1, 7, "回调文档.md", "webhook 流程", "", time.Time{})
require.NoError(t, database.Create(&models.SaInboxItem{ProjectID: 1, CreatedBy: 7, SourceType: "text", Title: "收集项", Body: "webhook 待整理"}).Error)
require.NoError(t, database.Create(&models.SaAISession{ProjectID: 1, CreatedBy: 7, Title: "AI 分析", Context: "webhook 问答"}).Error)
@@ -133,10 +132,23 @@ func TestSearchOnlyReturnsNavigableProjectTaskAndNoteResults(t *testing.T) {
for _, result := range results {
types[result.Type] = true
}
require.Equal(t, map[string]bool{"project": true, "task": true, "note": true}, types)
require.Equal(t, map[string]bool{"project": true, "task": true, "document": true}, types)
require.Len(t, results, 3)
}
func createSearchDocument(t *testing.T, database *gorm.DB, projectID, userID uint, name, markdown, identity string, updatedAt time.Time) models.SaDocument {
t.Helper()
document := models.SaDocument{
Identity: identity, ProjectID: projectID, CreatedBy: userID, Kind: models.DocumentKindFile,
Name: name, NormalizedName: strings.ToLower(name), Extension: ".md",
MimeType: "text/markdown", Size: int64(len([]byte(markdown))), Revision: 1,
UpdatedAt: updatedAt,
}
require.NoError(t, database.Create(&document).Error)
require.NoError(t, database.Create(&models.SaDocumentContent{DocumentID: document.ID, Markdown: markdown}).Error)
return document
}
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)

View File

@@ -29,13 +29,13 @@ func TestPostgresMovePreventsOldProjectShareFromBeingInsertedConcurrently(t *tes
require.NoError(t, database.Create(&first).Error)
require.NoError(t, database.Create(&second).Error)
task := models.SaTask{ProjectID: first.ID, CreatedBy: owner.ID, Title: "Move", Status: "open"}
note := models.SaNote{ProjectID: first.ID, CreatedBy: owner.ID, Title: "Old context", Markdown: "private"}
document := models.SaDocument{ProjectID: first.ID, CreatedBy: owner.ID, Kind: models.DocumentKindFile, Name: "Old context.md", NormalizedName: "old context.md", Extension: ".md", Revision: 1}
require.NoError(t, database.Create(&task).Error)
require.NoError(t, database.Create(&note).Error)
require.NoError(t, database.Create(&document).Error)
t.Cleanup(func() {
database.Where("task_id = ?", task.ID).Delete(&models.SaTaskShare{})
database.Where("entity_type = ? AND entity_id = ?", "task", task.ID).Delete(&models.SaProjectEvent{})
database.Delete(&note)
database.Delete(&document)
database.Delete(&task)
database.Delete(&first)
database.Delete(&second)
@@ -78,7 +78,7 @@ func TestPostgresMovePreventsOldProjectShareFromBeingInsertedConcurrently(t *tes
}
shareResult := make(chan error, 1)
go func() { shareResult <- service.ShareObject(task.ID, "note", note.ID) }()
go func() { shareResult <- service.ShareObject(task.ID, "document", document.ID) }()
select {
case err := <-shareResult:
t.Fatalf("ShareObject returned before the moving transaction released its task lock: %v", err)

View File

@@ -50,9 +50,9 @@ func TestTaskRegistrarMovesTaskByIdentityAndClearsForeignProjectTag(t *testing.T
require.NoError(t, database.Create(&tag).Error)
task := models.SaTask{ProjectID: first.ID, CreatedBy: 1, TagID: &tag.ID, Title: "迁移任务", Status: "open"}
require.NoError(t, database.Create(&task).Error)
note := models.SaNote{ProjectID: first.ID, CreatedBy: 1, Title: "旧项目资料", Markdown: "仅可在 Alpha 分享"}
require.NoError(t, database.Create(&note).Error)
require.NoError(t, NewService(database).ShareObject(task.ID, "note", note.ID))
document := models.SaDocument{ProjectID: first.ID, CreatedBy: 1, Kind: models.DocumentKindFile, Name: "旧项目资料.md", NormalizedName: "旧项目资料.md", Extension: ".md", Revision: 1}
require.NoError(t, database.Create(&document).Error)
require.NoError(t, NewService(database).ShareObject(task.ID, "document", document.ID))
body := bytes.NewBufferString(fmt.Sprintf(`{"title":"迁移任务","description":"已移动","completed":false,"nextProjectId":%q}`, strings.ToUpper(second.Identity)))
req := httptest.NewRequest(http.MethodPatch, "/api/v1/projects/"+first.Identity+"/tasks/"+task.Identity, body)
req.Header.Set("Content-Type", "application/json")

View File

@@ -63,9 +63,9 @@ func (s *Service) Assign(taskID uint, assigneeIdentity string) error {
})
}
// ShareObject 只允许显式分享 note/source并在同一事务中验证关联对象属于任务所在项目。
// ShareObject only allows an explicitly shared document from the task project.
func (s *Service) ShareObject(taskID uint, objectType string, objectID uint) error {
if objectType != "note" && objectType != "source" {
if objectType != "document" {
return errors.New("unsupported shared object type")
}
return s.database().Transaction(func(tx *gorm.DB) error {
@@ -73,7 +73,7 @@ func (s *Service) ShareObject(taskID uint, objectType string, objectID uint) err
if err != nil {
return err
}
if err := ensureSharedObjectInProject(tx, task.ProjectID, objectType, objectID); err != nil {
if err := ensureSharedObjectInProject(tx, task.ProjectID, objectID); err != nil {
return err
}
if err := tx.Create(&models.SaTaskShare{TaskID: taskID, ObjectType: objectType, ObjectID: objectID}).Error; err != nil {
@@ -193,7 +193,7 @@ func (s *Service) Update(ownerID uint, projectIdentity, taskIdentity string, inp
task.TagID = tagID
task.TagIdentity = tagIdentity
if targetProject.ID != currentProject.ID {
// 现有分享都在原项目边界内;移动后必须清空,避免旧项目 note/source 继续对被指派人可见。
// 现有分享都在原项目边界内;移动后必须清空,避免旧项目 document 继续对被指派人可见。
if err := tx.Where("task_id = ?", task.ID).Delete(&models.SaTaskShare{}).Error; err != nil {
return err
}
@@ -267,24 +267,13 @@ func utcOptionalTime(value *time.Time) *time.Time {
return &result
}
func ensureSharedObjectInProject(tx *gorm.DB, projectID uint, objectType string, objectID uint) error {
switch objectType {
case "note":
func ensureSharedObjectInProject(tx *gorm.DB, projectID uint, objectID uint) error {
var count int64
if err := tx.Model(&models.SaNote{}).Where("id = ? AND project_id = ?", objectID, projectID).Count(&count).Error; err != nil {
if err := tx.Model(&models.SaDocument{}).Where("id = ? AND project_id = ?", objectID, projectID).Count(&count).Error; err != nil {
return err
}
if count == 0 {
return errors.New("shared object not found in task project")
}
case "source":
var count int64
if err := tx.Model(&models.SaSource{}).Where("id = ? AND project_id = ?", objectID, projectID).Count(&count).Error; err != nil {
return err
}
if count == 0 {
return errors.New("shared object not found in task project")
}
}
return nil
}

View File

@@ -24,11 +24,11 @@ func TestUpdateLocksTaskBeforeProjectValidation(t *testing.T) {
func TestShareObjectLocksTaskBeforeObjectValidation(t *testing.T) {
database, _, project, task := newTaskLockFixture(t)
note := models.SaNote{ProjectID: project.ID, CreatedBy: task.CreatedBy, Title: "Context", Markdown: "Private"}
require.NoError(t, database.Create(&note).Error)
document := models.SaDocument{ProjectID: project.ID, CreatedBy: task.CreatedBy, Kind: models.DocumentKindFile, Name: "Context.md", NormalizedName: "context.md", Extension: ".md", Revision: 1}
require.NoError(t, database.Create(&document).Error)
queries := captureTaskQueryOrder(t, database)
err := NewService(database).ShareObject(task.ID, "note", note.ID)
err := NewService(database).ShareObject(task.ID, "document", document.ID)
require.NoError(t, err)
require.NotEmpty(t, *queries)
@@ -39,21 +39,21 @@ func TestAssigneeOnlySeesExplicitlySharedObjects(t *testing.T) {
database := newTestDB(t)
assigneeID := uint(2)
task := models.SaTask{ProjectID: 1, CreatedBy: 1, AssigneeID: &assigneeID, Title: "处理合同"}
note := models.SaNote{ProjectID: 1, CreatedBy: 1, Title: "合同背景", Markdown: "只在共享后可见"}
document := models.SaDocument{ProjectID: 1, CreatedBy: 1, Kind: models.DocumentKindFile, Name: "合同背景.md", NormalizedName: "合同背景.md", Extension: ".md", Revision: 1}
require.NoError(t, database.Create(&task).Error)
require.NoError(t, database.Create(&note).Error)
require.NoError(t, database.Create(&document).Error)
service := NewService()
before, err := service.VisibleLinkedObjects(task.ID, assigneeID)
require.NoError(t, err)
require.Empty(t, before)
require.NoError(t, service.ShareObject(task.ID, "note", note.ID))
require.NoError(t, service.ShareObject(task.ID, "document", document.ID))
after, err := service.VisibleLinkedObjects(task.ID, assigneeID)
require.NoError(t, err)
require.Len(t, after, 1)
require.Equal(t, "note", after[0].ObjectType)
require.Equal(t, note.Identity, after[0].ObjectID)
require.Equal(t, "document", after[0].ObjectType)
require.Equal(t, document.Identity, after[0].ObjectID)
}
func TestShareObjectRejectsUnsupportedType(t *testing.T) {
@@ -68,12 +68,12 @@ func TestShareObjectRejectsUnsupportedType(t *testing.T) {
func TestShareObjectRejectsObjectFromAnotherProject(t *testing.T) {
database := newTestDB(t)
task := models.SaTask{ProjectID: 1, CreatedBy: 1, Title: "Review"}
note := models.SaNote{ProjectID: 2, CreatedBy: 1, Title: "Other project", Markdown: "Private context"}
document := models.SaDocument{ProjectID: 2, CreatedBy: 1, Kind: models.DocumentKindFile, Name: "Other project.md", NormalizedName: "other project.md", Extension: ".md", Revision: 1}
require.NoError(t, database.Create(&task).Error)
require.NoError(t, database.Create(&note).Error)
require.NoError(t, database.Create(&document).Error)
service := NewService()
err := service.ShareObject(task.ID, "note", note.ID)
err := service.ShareObject(task.ID, "document", document.ID)
require.ErrorContains(t, err, "shared object not found in task project")
}

View File

@@ -0,0 +1,85 @@
package models
import (
"time"
"gorm.io/gorm"
)
const (
DocumentKindFolder = "folder"
DocumentKindFile = "file"
)
type SaDocument struct {
ID uint `gorm:"primaryKey"`
Identity string `gorm:"type:char(36);uniqueIndex"`
ProjectID uint `gorm:"index;not null;uniqueIndex:uidx_sa_document_sibling,priority:1"`
ProjectIdentity string `gorm:"type:char(36);index"`
ParentID *uint `gorm:"index"`
ParentIdentity *string `gorm:"type:char(36);index"`
ParentScope string `gorm:"type:char(36);not null;default:'';uniqueIndex:uidx_sa_document_sibling,priority:2"`
CreatedBy uint `gorm:"index;not null"`
CreatedByIdentity string `gorm:"type:char(36);index"`
SourceInboxItemID *uint `gorm:"index"`
SourceInboxItemIdentity *string `gorm:"type:char(36);index"`
Kind string `gorm:"size:16;not null"`
Name string `gorm:"not null"`
NormalizedName string `gorm:"not null;uniqueIndex:uidx_sa_document_sibling,priority:3"`
Extension string `gorm:"size:32"`
MimeType string `gorm:"size:255"`
Size int64 `gorm:"not null;default:0"`
Revision uint64 `gorm:"not null;default:1"`
SortOrder int `gorm:"not null;default:0"`
ScanStatus string `gorm:"size:24;not null;default:'not_scanned'"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
}
func (SaDocument) TableName() string {
return "sa_documents"
}
type SaDocumentContent struct {
ID uint `gorm:"primaryKey"`
DocumentID uint `gorm:"uniqueIndex;not null"`
Markdown string `gorm:"type:text"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (SaDocumentContent) TableName() string {
return "sa_document_contents"
}
type SaDocumentBlob struct {
ID uint `gorm:"primaryKey"`
DocumentID uint `gorm:"uniqueIndex;not null"`
StorageKey string `gorm:"size:64;uniqueIndex;not null"`
RelativePath string `gorm:"not null"`
OriginalName string `gorm:"not null"`
Checksum string `gorm:"size:64;not null"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (SaDocumentBlob) TableName() string {
return "sa_document_blobs"
}
type SaDocumentShare struct {
ID uint `gorm:"primaryKey"`
Identity string `gorm:"type:char(36);uniqueIndex"`
DocumentID uint `gorm:"index;not null"`
DocumentIdentity string `gorm:"type:char(36);index"`
CreatedBy uint `gorm:"index;not null"`
TokenHash string `gorm:"size:64;uniqueIndex;not null"`
ExpiresAt time.Time `gorm:"index;not null"`
RevokedAt *time.Time `gorm:"index"`
CreatedAt time.Time
}
func (SaDocumentShare) TableName() string {
return "sa_document_shares"
}

View File

@@ -75,7 +75,7 @@ func (m *SaTask) BeforeCreate(tx *gorm.DB) error {
return resolveOptionalIdentity(tx, &SaInboxItem{}, m.SourceInboxItemID, &m.SourceInboxItemIdentity)
}
func (m *SaNote) BeforeCreate(tx *gorm.DB) error {
func (m *SaDocument) BeforeCreate(tx *gorm.DB) error {
if err := ensureIdentity(&m.Identity); err != nil {
return err
}
@@ -85,20 +85,20 @@ func (m *SaNote) BeforeCreate(tx *gorm.DB) error {
if err := resolveIdentity(tx, &SaUser{}, m.CreatedBy, &m.CreatedByIdentity); err != nil {
return err
}
if err := resolveOptionalIdentity(tx, &SaDocument{}, m.ParentID, &m.ParentIdentity); err != nil {
return err
}
return resolveOptionalIdentity(tx, &SaInboxItem{}, m.SourceInboxItemID, &m.SourceInboxItemIdentity)
}
func (m *SaSource) BeforeCreate(tx *gorm.DB) error {
func (m *SaDocumentShare) BeforeCreate(tx *gorm.DB) error {
if err := ensureIdentity(&m.Identity); err != nil {
return err
}
if err := resolveIdentity(tx, &SaProject{}, m.ProjectID, &m.ProjectIdentity); err != nil {
if err := resolveIdentity(tx, &SaDocument{}, m.DocumentID, &m.DocumentIdentity); err != nil {
return err
}
if err := resolveIdentity(tx, &SaUser{}, m.CreatedBy, &m.CreatedByIdentity); err != nil {
return err
}
return resolveOptionalIdentity(tx, &SaInboxItem{}, m.SourceInboxItemID, &m.SourceInboxItemIdentity)
return nil
}
func (m *SaAISession) BeforeCreate(tx *gorm.DB) error {
@@ -233,10 +233,8 @@ func resolveEntityIdentity(tx *gorm.DB, entityType string, entityID uint, identi
return resolveIdentity(tx, &SaInboxItem{}, entityID, identity)
case "task":
return resolveIdentity(tx, &SaTask{}, entityID, identity)
case "note":
return resolveIdentity(tx, &SaNote{}, entityID, identity)
case "source":
return resolveIdentity(tx, &SaSource{}, entityID, identity)
case "document":
return resolveIdentity(tx, &SaDocument{}, entityID, identity)
case "ai_session":
return resolveIdentity(tx, &SaAISession{}, entityID, identity)
case "tag":

View File

@@ -31,6 +31,7 @@ func runVersionedMigrations(database *gorm.DB) error {
migrations := []versionedMigration{
{version: 1, name: "normalize_project_identifiers", run: normalizeLegacyProjectIdentifiers},
{version: 2, name: "deduplicate_project_tags", run: deduplicateLegacyProjectTags},
{version: 3, name: "replace_notes_sources_with_documents", run: replaceNotesSourcesWithDocuments},
}
for _, migration := range migrations {
// Connection callbacks can provide an initialized Gorm session. Start each
@@ -63,6 +64,28 @@ func runVersionedMigrations(database *gorm.DB) error {
return nil
}
func replaceNotesSourcesWithDocuments(tx *gorm.DB) (map[string]int, error) {
deletedShares := int64(0)
if tx.Migrator().HasTable(&SaTaskShare{}) {
result := tx.Where("object_type IN ?", []string{"note", "source"}).Delete(&SaTaskShare{})
if result.Error != nil {
return nil, result.Error
}
deletedShares = result.RowsAffected
}
dropped := 0
for _, table := range []string{"sa_notes", "sa_sources"} {
if !tx.Migrator().HasTable(table) {
continue
}
if err := tx.Migrator().DropTable(table); err != nil {
return nil, err
}
dropped++
}
return map[string]int{"dropped_tables": dropped, "deleted_task_shares": int(deletedShares)}, nil
}
func normalizeLegacyProjectIdentifiers(tx *gorm.DB) (map[string]int, error) {
var projects []SaProject
if err := tx.Model(&SaProject{}).Select("id", "owner_id", "identifier").Order("owner_id asc, id asc").Find(&projects).Error; err != nil {

View File

@@ -63,14 +63,14 @@ func TestAutoMigrateUpgradesLegacyProjectsAndTagsWithoutLosingAssociations(t *te
var migrationCount int64
require.NoError(t, database.Table("sa_schema_migrations").Count(&migrationCount).Error)
require.Equal(t, int64(2), migrationCount)
require.Equal(t, int64(3), migrationCount)
var auditDetails []string
require.NoError(t, database.Table("sa_schema_migrations").Order("version asc").Pluck("details", &auditDetails).Error)
require.Contains(t, auditDetails[0], `"updated":3`)
require.Contains(t, auditDetails[1], `"deduplicated":1`)
require.NoError(t, AutoMigrate(database), "versioned migrations must be safe to run again")
require.NoError(t, database.Table("sa_schema_migrations").Count(&migrationCount).Error)
require.Equal(t, int64(2), migrationCount)
require.Equal(t, int64(3), migrationCount)
require.Error(t, database.Exec(`INSERT INTO sa_projects (owner_id, name, identifier) VALUES (7, 'still duplicate', 'DUP')`).Error)
require.Error(t, database.Exec(`INSERT INTO sa_tags (project_id, name) VALUES (1, 'UI')`).Error)

View File

@@ -36,8 +36,10 @@ func AutoMigrate(database *gorm.DB) error {
&SaInboxItem{},
&SaInboxSuggestion{},
&SaTask{},
&SaNote{},
&SaSource{},
&SaDocument{},
&SaDocumentContent{},
&SaDocumentBlob{},
&SaDocumentShare{},
&SaAIExpertCategory{},
&SaAIExpertItem{},
&SaAISession{},

View File

@@ -1,22 +0,0 @@
package models
import "time"
type SaNote struct {
ID uint `gorm:"primaryKey"`
Identity string `gorm:"type:char(36);uniqueIndex"`
ProjectID uint `gorm:"index;not null"`
ProjectIdentity string `gorm:"type:char(36);index"`
CreatedBy uint `gorm:"index;not null"`
CreatedByIdentity string `gorm:"type:char(36);index"`
SourceInboxItemID *uint `gorm:"index"`
SourceInboxItemIdentity *string `gorm:"type:char(36);index"`
Title string `gorm:"not null"`
Markdown string `gorm:"type:text"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (SaNote) TableName() string {
return "sa_notes"
}

View File

@@ -1,25 +0,0 @@
package models
import "time"
type SaSource struct {
ID uint `gorm:"primaryKey"`
Identity string `gorm:"type:char(36);uniqueIndex"`
ProjectID uint `gorm:"index;not null"`
ProjectIdentity string `gorm:"type:char(36);index"`
CreatedBy uint `gorm:"index;not null"`
CreatedByIdentity string `gorm:"type:char(36);index"`
SourceInboxItemID *uint `gorm:"index"`
SourceInboxItemIdentity *string `gorm:"type:char(36);index"`
Kind string `gorm:"not null"`
Title string `gorm:"not null"`
URL string
FilePath string
ContentText string `gorm:"type:text"`
CreatedAt time.Time
UpdatedAt time.Time
}
func (SaSource) TableName() string {
return "sa_sources"
}

View File

@@ -174,19 +174,14 @@ func seedProjectA1(tx *gorm.DB, userID uint, projectID uint) error {
return err
}
}
for _, note := range []models.SaNote{
{ProjectID: projectID, CreatedBy: userID, Title: "用户权限体系设计文档 v2.1", Markdown: "# 权限体系\n\n围绕项目、任务和资料分享做保守授权。"},
{ProjectID: projectID, CreatedBy: userID, Title: "导出功能技术方案评审", Markdown: "# 导出功能\n\n记录权限校验和任务队列方案。"},
for _, document := range []struct {
name string
markdown string
}{
{name: "用户权限体系设计文档 v2.1.md", markdown: "# 权限体系\n\n围绕项目、任务和资料分享做保守授权。"},
{name: "导出功能技术方案评审.md", markdown: "# 导出功能\n\n记录权限校验和任务队列方案。"},
} {
if err := firstOrCreateNote(tx, note); err != nil {
return err
}
}
for _, source := range []models.SaSource{
{ProjectID: projectID, CreatedBy: userID, Kind: "file", Title: "2026 Q3 竞品功能对比表", FilePath: "projects/a1/competitors.xlsx", ContentText: "竞品功能矩阵和价格摘要"},
{ProjectID: projectID, CreatedBy: userID, Kind: "link", Title: "客户访谈记录索引", URL: "https://example.com/interviews", ContentText: "客户访谈和问题归类"},
} {
if err := firstOrCreateSource(tx, source); err != nil {
if err := firstOrCreateDocument(tx, projectID, userID, document.name, document.markdown); err != nil {
return err
}
}
@@ -261,12 +256,25 @@ func firstOrCreateAISession(tx *gorm.DB, session models.SaAISession) error {
return firstOrCreate(tx, &models.SaAISession{}, "project_id = ? AND title = ?", []any{session.ProjectID, session.Title}, session)
}
func firstOrCreateNote(tx *gorm.DB, note models.SaNote) error {
return firstOrCreate(tx, &models.SaNote{}, "project_id = ? AND title = ?", []any{note.ProjectID, note.Title}, note)
}
func firstOrCreateSource(tx *gorm.DB, source models.SaSource) error {
return firstOrCreate(tx, &models.SaSource{}, "project_id = ? AND title = ?", []any{source.ProjectID, source.Title}, source)
func firstOrCreateDocument(tx *gorm.DB, projectID, userID uint, name, markdown string) error {
var document models.SaDocument
err := tx.Where("project_id = ? AND normalized_name = ?", projectID, strings.ToLower(name)).First(&document).Error
if err == nil {
return nil
}
if err != gorm.ErrRecordNotFound {
return err
}
document = models.SaDocument{
ProjectID: projectID, CreatedBy: userID, Kind: models.DocumentKindFile,
Name: name, NormalizedName: strings.ToLower(name), Extension: ".md",
MimeType: "text/markdown; charset=utf-8", Size: int64(len([]byte(markdown))),
Revision: 1, ScanStatus: "not_scanned",
}
if err := tx.Create(&document).Error; err != nil {
return err
}
return tx.Create(&models.SaDocumentContent{DocumentID: document.ID, Markdown: markdown}).Error
}
func firstOrCreateCronPlan(tx *gorm.DB, plan models.SaCronPlan) error {

View File

@@ -147,8 +147,7 @@ func TestPostgresDemoSeedSerializesConcurrentRunsForSameOwner(t *testing.T) {
assertPostgresDemoCount(t, database, &models.SaTask{}, "project_id IN ?", []any{projectIDs}, 6)
assertPostgresDemoCount(t, database, &models.SaInboxItem{}, "project_id IN ?", []any{projectIDs}, 7)
assertPostgresDemoCount(t, database, &models.SaAISession{}, "project_id = ?", []any{projects[0].ID}, 4)
assertPostgresDemoCount(t, database, &models.SaNote{}, "project_id = ?", []any{projects[0].ID}, 2)
assertPostgresDemoCount(t, database, &models.SaSource{}, "project_id = ?", []any{projects[0].ID}, 2)
assertPostgresDemoCount(t, database, &models.SaDocument{}, "project_id = ?", []any{projects[0].ID}, 2)
assertPostgresDemoCount(t, database, &models.SaCronPlan{}, "project_id = ?", []any{projects[0].ID}, 3)
assertPostgresDemoCount(t, database, &models.SaProjectChannel{}, "project_id = ?", []any{projects[0].ID}, 2)
}
@@ -217,8 +216,7 @@ func cleanupPostgresDemoSeed(database *gorm.DB, userID uint) {
}
database.Where("project_id IN ?", projectIDs).Delete(&models.SaProjectEvent{})
database.Where("project_id IN ?", projectIDs).Delete(&models.SaTask{})
database.Where("project_id IN ?", projectIDs).Delete(&models.SaNote{})
database.Where("project_id IN ?", projectIDs).Delete(&models.SaSource{})
database.Where("project_id IN ?", projectIDs).Delete(&models.SaDocument{})
database.Where("project_id IN ?", projectIDs).Delete(&models.SaAISession{})
database.Where("project_id IN ?", projectIDs).Delete(&models.SaTag{})
database.Where("project_id IN ?", projectIDs).Delete(&models.SaProjectChannel{})

View File

@@ -35,7 +35,7 @@ func TestDemoSeedCreatesFrontendWorkspaceData(t *testing.T) {
require.GreaterOrEqual(t, len(workspace.Inbox), 4)
require.GreaterOrEqual(t, len(workspace.Tasks), 3)
require.GreaterOrEqual(t, len(workspace.AISessions), 4)
require.GreaterOrEqual(t, len(workspace.NotesSources), 3)
require.GreaterOrEqual(t, len(workspace.Documents), 2)
require.GreaterOrEqual(t, len(workspace.CronPlans), 3)
for _, plan := range workspace.CronPlans {
require.Empty(t, plan.LastResult, "演示数据不得声称计划已执行")

View File

@@ -0,0 +1,14 @@
# 项目 Documents 工作台实施计划
1. 新建 document 模型和版本化破坏性迁移,删除旧 note/source 模型注册。
2. 新建聚焦的 `internal/logic/documents` 服务与 HTTP handler实现树、内容、上传、移动、
删除、下载、分享和导出。
3. 将工作台聚合、搜索、Inbox、探索沉淀、任务关联和 seed 切换为 document。
4. 新建前端 documents API、类型与跨频道 intent。
5. 使用 CodeMirror 6 实现全画布文件树、多标签编辑器、安全 Markdown 预览和标签持久化。
6. 实现 PDF、图片、文本、音视频、DOCX、XLS/XLSX 预览适配器。
7. 实现三并发上传队列、50 MB 校验、正文拖入根目录和文件树底部进度面板。
8. 实现 Markdown/PDF/DOCX 导出和匿名限时分享。
9. 增加后端服务/handler 测试与前端 API/交互检查。
10. 运行 `go test ./...``node scripts/visual-check.mjs``npm run build``npm run lint`
修复后创建一个聚焦提交。

View File

@@ -0,0 +1,101 @@
# 项目笔记资料Documents工作台设计
## 目标
将项目内原有的笔记与资料能力破坏性重构为统一的 `document` 领域。技术命名只使用
`document/documents`,中文产品名称继续使用“笔记资料”。
本次不迁移旧 `SaNote``SaSource` 数据,也不保留旧 API。
## 数据模型
- `SaDocument`:项目内树节点,支持 `folder``file`保存父节点、名称、MIME、扩展名、
大小、修订号、来源 Inbox 和软删除状态。
- `SaDocumentContent`Markdown 可编辑正文,与文件节点一对一。
- `SaDocumentBlob`:二进制文件存储元数据,与文件节点一对一;物理路径只能由 document
文件服务构造。
- `SaDocumentShare`:匿名只读预览链接,数据库只保存随机 token 的哈希、过期时间和撤销时间。
目录内名称忽略大小写唯一。手工重命名遇到重名返回冲突;新建与上传自动追加 `(1)`
## 后端契约
API 根路径为 `/api/v1/projects/:projectId/documents`,提供:
- 获取项目文档树;
- 新建目录与 Markdown
- 上传单个文件(单文件 50 MB
- 读取文档详情和内容;
- 使用 `revision` 更新 Markdown冲突时拒绝覆盖
- 重命名、移动与软删除;
- 下载原文件;
- 将 Markdown 导出为 `.md``.pdf``.docx`
- 创建、列出和撤销有效期分享链接。
匿名分享 API 只暴露单个文档的只读预览,不暴露项目、目录树或创建人,不允许下载。
无效、过期与撤销 token 均返回 404。
旧表和旧任务关联在版本化迁移中直接删除,不做数据转换。
## 页面布局
保留工作台顶部栏、项目 rail、项目 sidebar 与状态栏。`documents` 频道内容区采用无标题、
无副标题、无卡片边距的全画布布局:
- 左侧:可调整宽度、可折叠的文件树;顶部只有新建目录和新建 Markdown 图标;
- 右侧VS Code 风格横向多标签区与主编辑/预览区;
- 标签栏末尾固定编辑/预览、保存、导出和分享操作;
- 文件树底部显示可展开的上传队列和汇总进度。
目录优先并按名称自然排序。文件和目录可拖动移动,禁止目录移入自身或后代。正文侧拖入
的文件一律上传至项目根目录。
## 编辑、标签与自动保存
- Markdown 使用 CodeMirror 6
- Markdown 编辑与预览采用单面切换;
- 没有标签时显示本地 `新建文件1.md`,首次输入或手工保存后才创建正式文档;
- 停止输入 800ms 后自动保存;
- 标签展示未保存、保存中、已保存和失败状态;
- 保存失败时保留本地草稿并重试;
- 标签按项目保存到浏览器;同一文档只打开一个标签;
- 服务端使用单调递增 `revision` 防止多窗口静默覆盖。
## 预览与导出范围
- Markdown编辑和安全渲染预览
- PDF内嵌预览
- 图片PNG、JPEG、GIF、WebP、SVGSVG 净化后预览);
- 文本和代码:只读文本;
- 音视频:浏览器原生播放器;
- DOCX只读内容预览
- XLS/XLSX只读表格预览
- 其他格式:显示元数据并允许项目成员下载。
Markdown 导出支持 Markdown、PDF 和 DOCX。导出由后端生成临时响应不写回文件树。
PDF 使用随服务端发布的中文字体。复杂 HTML、Mermaid、数学公式和远程图片不在本轮保证范围。
## 上传
每个文件独立请求,单文件上限 50 MB客户端最多并发 3 个。上传区显示当前文件、总进度、
成功/失败计数并允许展开查看、取消和重试。Markdown 上传后成为可编辑数据库正文,其他
文件作为二进制文档。
## 跨频道入口
前端使用幂等的 `DocumentOpenIntent` 接收其他频道的 `create/open` 请求。Inbox、AI 和探索页
只做接入 document 的最小改动AI 内容仍须用户确认后才能成为正式对象。Inbox 生成的文档
保留来源 Inbox ID。
## 安全
- 服务端校验项目所有权、父目录项目归属、扩展名、MIME 和内容嗅探结果;
- 磁盘使用随机存储键;
- Markdown/HTML/SVG 预览禁止脚本与危险 URL
- Office 预览不执行宏、外链或嵌入对象;
- 匿名分享页使用严格 CSP 和 `noindex`
- 为后续病毒扫描状态预留字段,本轮不接入病毒扫描。
## 响应式
桌面端使用左右分栏。窄屏下文件树与编辑器切换展示,不强制压缩为双栏。