feat(documents): rebuild project document workbench
This commit is contained in:
2081
apps/web_v1/package-lock.json
generated
2081
apps/web_v1/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ export type DatasetItemPageDTO = {
|
||||
}
|
||||
|
||||
export type DatasetDepositDTO = {
|
||||
noteId: string
|
||||
documentId: string
|
||||
projectId: string
|
||||
}
|
||||
|
||||
|
||||
189
apps/web_v1/src/api/documents.ts
Normal file
189
apps/web_v1/src/api/documents.ts
Normal 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) : ''
|
||||
}
|
||||
@@ -13,7 +13,7 @@ export type InboxItemDTO = {
|
||||
|
||||
export type InboxSuggestionDTO = {
|
||||
id: string
|
||||
kind: 'task' | 'note' | 'source'
|
||||
kind: 'task' | 'document'
|
||||
title: string
|
||||
body: string
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
78
apps/web_v1/src/pages/projects/document-markdown-editor.tsx
Normal file
78
apps/web_v1/src/pages/projects/document-markdown-editor.tsx
Normal 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} />
|
||||
}
|
||||
@@ -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="新建计划任务"
|
||||
|
||||
@@ -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':
|
||||
|
||||
821
apps/web_v1/src/pages/projects/project-documents.tsx
Normal file
821
apps/web_v1/src/pages/projects/project-documents.tsx
Normal 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)
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 '未知'
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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))),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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, "")
|
||||
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, "")
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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(¬e).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) {
|
||||
|
||||
BIN
backend/internal/logic/documents/assets/NotoSansSC.ttf
Normal file
BIN
backend/internal/logic/documents/assets/NotoSansSC.ttf
Normal file
Binary file not shown.
93
backend/internal/logic/documents/assets/OFL.txt
Normal file
93
backend/internal/logic/documents/assets/OFL.txt
Normal 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.
|
||||
557
backend/internal/logic/documents/handlers.go
Normal file
557
backend/internal/logic/documents/handlers.go
Normal 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
|
||||
}
|
||||
32
backend/internal/logic/documents/handlers_test.go
Normal file
32
backend/internal/logic/documents/handlers_test.go
Normal 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")
|
||||
}
|
||||
799
backend/internal/logic/documents/service.go
Normal file
799
backend/internal/logic/documents/service.go
Normal 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
|
||||
}
|
||||
126
backend/internal/logic/documents/service_test.go
Normal file
126
backend/internal/logic/documents/service_test.go
Normal 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
|
||||
}
|
||||
@@ -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", "资料创建失败")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(¬e).Error)
|
||||
require.Equal(t, "会议纪要", note.Title)
|
||||
require.Equal(t, fixture.project.ID, note.ProjectID)
|
||||
require.Equal(t, fixture.owner.ID, note.CreatedBy)
|
||||
requireSourceInboxIdentity(t, item, note.SourceInboxItemID, note.SourceInboxItemIdentity)
|
||||
|
||||
var source models.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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 的计划任务摘要。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(¬eCount).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(¬es).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 {
|
||||
|
||||
@@ -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, ¬e, &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)
|
||||
}
|
||||
|
||||
@@ -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(¬e).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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(¬es).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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(¬e).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(¬e)
|
||||
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)
|
||||
|
||||
@@ -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(¬e).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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(¬e).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(¬e).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(¬e).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")
|
||||
}
|
||||
|
||||
85
backend/internal/models/document.go
Normal file
85
backend/internal/models/document.go
Normal 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"
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -36,8 +36,10 @@ func AutoMigrate(database *gorm.DB) error {
|
||||
&SaInboxItem{},
|
||||
&SaInboxSuggestion{},
|
||||
&SaTask{},
|
||||
&SaNote{},
|
||||
&SaSource{},
|
||||
&SaDocument{},
|
||||
&SaDocumentContent{},
|
||||
&SaDocumentBlob{},
|
||||
&SaDocumentShare{},
|
||||
&SaAIExpertCategory{},
|
||||
&SaAIExpertItem{},
|
||||
&SaAISession{},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{})
|
||||
|
||||
@@ -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, "演示数据不得声称计划已执行")
|
||||
|
||||
@@ -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`,
|
||||
修复后创建一个聚焦提交。
|
||||
@@ -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、SVG(SVG 净化后预览);
|
||||
- 文本和代码:只读文本;
|
||||
- 音视频:浏览器原生播放器;
|
||||
- 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`;
|
||||
- 为后续病毒扫描状态预留字段,本轮不接入病毒扫描。
|
||||
|
||||
## 响应式
|
||||
|
||||
桌面端使用左右分栏。窄屏下文件树与编辑器切换展示,不强制压缩为双栏。
|
||||
Reference in New Issue
Block a user