Преглед изворни кода

feat(配置): 更新环境变量配置,添加开发和生产环境的API代理支持,增强安全性
feat(聊天面板): 添加下载链接验证,确保文档地址安全
feat(AI服务): 根据环境配置选择API URL和密钥,优化错误处理
feat(错误处理): 限制错误消息长度,避免过长信息影响用户体验
feat(编辑器): 添加块序列化功能,优化保存逻辑

Zhang Yice пре 1 месец
родитељ
комит
866fdafae1

+ 5 - 16
.env.example

@@ -13,24 +13,13 @@ VITE_APP_TITLE=AX Document Editor
13 13
 # Enable debug mode for development
14 14
 # Default: false
15 15
 VITE_DEBUG=false
16
+
17
+# Development-only direct AI endpoints. Never ship these keys in a production build.
16 18
 VITE_AI_API_URL=
17 19
 VITE_AI_API_KEY=
18 20
 VITE_WORKFLOW_API_URL=
19 21
 VITE_WORKFLOW_API_KEY=
20 22
 
21
-# AI Chat API Configuration
22
-# AI Platform API URL for chat completions (v1 - regular chat)
23
-VITE_AI_API_URL=http://114.242.25.27:3000/api/v1/chat/completions
24
-
25
-# AI Platform API Key
26
-# Get this from: 发布渠道 → API 访问 → 创建新 Key
27
-VITE_AI_API_KEY=your_api_key_here
28
-
29
-# Workflow API Configuration
30
-# Workflow API URL for document generation (v2 - with workflow)
31
-# This workflow connects to local backend export records API
32
-VITE_WORKFLOW_API_URL=http://114.242.25.27:3000/api/v2/chat/completions
33
-
34
-# Workflow API Key
35
-# Use the XAgent API key provided by the platform
36
-VITE_WORKFLOW_API_KEY=your_workflow_api_key_here
23
+# Production same-origin proxy endpoints. The proxy stores the upstream API keys server-side.
24
+VITE_AI_PROXY_URL=/api/v1/ai/chat
25
+VITE_WORKFLOW_PROXY_URL=/api/v1/ai/workflow

+ 13 - 1
src/components/ChatPanel/MessageItem.tsx

@@ -17,7 +17,11 @@ import { formatDate } from '../../utils/formatDate';
17 17
 import type { ChatMessage } from '../../types/chat';
18 18
 import { useDocumentStore } from '../../stores/documentStore';
19 19
 import { useChatStore } from '../../stores/chatStore';
20
-import { downloadBlob, getFileNameFromContentDisposition } from '../../utils/download';
20
+import {
21
+  downloadBlob,
22
+  getFileNameFromContentDisposition,
23
+  isAllowedHttpUrl,
24
+} from '../../utils/download';
21 25
 
22 26
 const { Text } = Typography;
23 27
 
@@ -154,6 +158,10 @@ const MessageItem: React.FC<MessageItemProps> = memo(
154 158
      */
155 159
     const handlePreviewClick = useCallback(async () => {
156 160
       if (!exportRecord || !onPreviewDocument || isCreatingDocument) return;
161
+      if (!isAllowedHttpUrl(exportRecord.downloadUrl)) {
162
+        antdMessage.error('文档地址不受信任,无法打开');
163
+        return;
164
+      }
157 165
 
158 166
       try {
159 167
         setIsCreatingDocument(true);
@@ -223,6 +231,10 @@ const MessageItem: React.FC<MessageItemProps> = memo(
223 231
       async (e: React.MouseEvent) => {
224 232
         e.stopPropagation(); // Prevent card click
225 233
         if (!exportRecord?.downloadUrl) return;
234
+        if (!isAllowedHttpUrl(exportRecord.downloadUrl)) {
235
+          antdMessage.error('文档地址不受信任,无法下载');
236
+          return;
237
+        }
226 238
         
227 239
         try {
228 240
           // Extract recordId from downloadUrl

+ 19 - 6
src/services/aiChatService.ts

@@ -78,12 +78,19 @@ interface AIChatConfig {
78 78
  * Get AI Chat configuration from environment variables
79 79
  */
80 80
 const getAIChatConfig = (): AIChatConfig => {
81
+  const isDevelopment = import.meta.env.DEV;
81 82
   return {
82
-    apiUrl: import.meta.env.VITE_AI_API_URL,
83
-    apiKey: import.meta.env.VITE_AI_API_KEY,
83
+    apiUrl: isDevelopment
84
+      ? import.meta.env.VITE_AI_API_URL
85
+      : import.meta.env.VITE_AI_PROXY_URL,
86
+    apiKey: isDevelopment ? import.meta.env.VITE_AI_API_KEY : '',
84 87
   };
85 88
 };
86 89
 
90
+const getAuthorizationHeaders = (apiKey: string): Record<string, string> => (
91
+  apiKey ? { Authorization: `Bearer ${apiKey}` } : {}
92
+);
93
+
87 94
 /**
88 95
  * Send a chat completion request to AI platform
89 96
  *
@@ -97,7 +104,10 @@ export const sendChatCompletion = async (
97 104
   try {
98 105
     const config = getAIChatConfig();
99 106
 
100
-    if (!config.apiUrl || !config.apiKey) {
107
+    if (!config.apiUrl) {
108
+      if (import.meta.env.PROD) {
109
+        throw new Error('AI 服务未配置,请先配置服务端代理');
110
+      }
101 111
       return mockChatCompletion(request);
102 112
     }
103 113
 
@@ -105,7 +115,7 @@ export const sendChatCompletion = async (
105 115
       method: 'POST',
106 116
       headers: {
107 117
         'Content-Type': 'application/json',
108
-        Authorization: `Bearer ${config.apiKey}`,
118
+        ...getAuthorizationHeaders(config.apiKey),
109 119
       },
110 120
       body: JSON.stringify({
111 121
         chatId: request.chatId,
@@ -154,7 +164,10 @@ export const sendStreamingChatCompletion = async (
154 164
   try {
155 165
     const config = getAIChatConfig();
156 166
 
157
-    if (!config.apiUrl || !config.apiKey) {
167
+    if (!config.apiUrl) {
168
+      if (import.meta.env.PROD) {
169
+        throw new Error('AI 服务未配置,请先配置服务端代理');
170
+      }
158 171
       const mockResponse = await mockChatCompletion(request);
159 172
       for (const chunk of mockResponse.content.split(' ')) {
160 173
         await new Promise((resolve) => setTimeout(resolve, 50));
@@ -167,7 +180,7 @@ export const sendStreamingChatCompletion = async (
167 180
       method: 'POST',
168 181
       headers: {
169 182
         'Content-Type': 'application/json',
170
-        Authorization: `Bearer ${config.apiKey}`,
183
+        ...getAuthorizationHeaders(config.apiKey),
171 184
       },
172 185
       body: JSON.stringify({
173 186
         ...request,

+ 15 - 5
src/services/api.ts

@@ -59,6 +59,14 @@ const getBaseURL = (): string => {
59 59
  * Default request timeout in milliseconds (30 seconds)
60 60
  */
61 61
 const DEFAULT_TIMEOUT = 30000;
62
+const MAX_ERROR_MESSAGE_LENGTH = 500;
63
+
64
+const limitErrorMessage = (value: string): string => {
65
+  const normalized = value.trim();
66
+  return normalized.length > MAX_ERROR_MESSAGE_LENGTH
67
+    ? `${normalized.slice(0, MAX_ERROR_MESSAGE_LENGTH)}...`
68
+    : normalized;
69
+};
62 70
 
63 71
 /**
64 72
  * Create and configure axios instance
@@ -184,15 +192,15 @@ export const normalizeAxiosError = (error: AxiosError): ApiError => {
184 192
 
185 193
     // Extract error message from response
186 194
     if (apiData?.message) {
187
-      message = apiData.message;
195
+      message = limitErrorMessage(apiData.message);
188 196
     } else if (typeof data === 'string') {
189
-      message = data;
197
+      message = limitErrorMessage(data);
190 198
     }
191 199
 
192 200
     // Specific client error messages
193 201
     switch (status) {
194 202
       case 400:
195
-        message = apiData?.message || '请求参数错误';
203
+        message = apiData?.message ? limitErrorMessage(apiData.message) : '请求参数错误';
196 204
         break;
197 205
       case 401:
198 206
         message = '未授权,请先登录';
@@ -204,7 +212,7 @@ export const normalizeAxiosError = (error: AxiosError): ApiError => {
204 212
         message = '请求的资源不存在';
205 213
         break;
206 214
       case 422:
207
-        message = apiData?.message || '数据验证失败';
215
+        message = apiData?.message ? limitErrorMessage(apiData.message) : '数据验证失败';
208 216
         break;
209 217
     }
210 218
 
@@ -219,7 +227,9 @@ export const normalizeAxiosError = (error: AxiosError): ApiError => {
219 227
 
220 228
   // Server errors (5xx)
221 229
   if (status >= 500) {
222
-    const message = apiData?.message || '服务器错误,请稍后重试'
230
+    const message = apiData?.message
231
+      ? limitErrorMessage(apiData.message)
232
+      : '服务器错误,请稍后重试';
223 233
 
224 234
     return {
225 235
       type: 'server',

+ 10 - 3
src/services/workflowService.ts

@@ -81,12 +81,19 @@ interface WorkflowConfig {
81 81
  * Get Workflow configuration from environment variables
82 82
  */
83 83
 const getWorkflowConfig = (): WorkflowConfig => {
84
+  const isDevelopment = import.meta.env.DEV;
84 85
   return {
85
-    apiUrl: import.meta.env.VITE_WORKFLOW_API_URL,
86
-    apiKey: import.meta.env.VITE_WORKFLOW_API_KEY,
86
+    apiUrl: isDevelopment
87
+      ? import.meta.env.VITE_WORKFLOW_API_URL
88
+      : import.meta.env.VITE_WORKFLOW_PROXY_URL,
89
+    apiKey: isDevelopment ? import.meta.env.VITE_WORKFLOW_API_KEY : '',
87 90
   };
88 91
 };
89 92
 
93
+const getAuthorizationHeaders = (apiKey: string): Record<string, string> => (
94
+  apiKey ? { Authorization: `Bearer ${apiKey}` } : {}
95
+);
96
+
90 97
 /**
91 98
  * Check if user input should trigger document generation workflow
92 99
  *
@@ -170,7 +177,7 @@ export const triggerDocumentWorkflow = async (
170 177
       method: 'POST',
171 178
       headers: {
172 179
         'Content-Type': 'application/json',
173
-        Authorization: `Bearer ${config.apiKey}`,
180
+        ...getAuthorizationHeaders(config.apiKey),
174 181
       },
175 182
       body: JSON.stringify({
176 183
         chatId: uniqueChatId, // 带时间戳的唯一ID,避免工作流缓存

+ 1 - 1
src/stores/chatStore.ts

@@ -79,7 +79,7 @@ const loadSessionsFromStorage = (): ChatSession[] => {
79 79
         messages: session.messages.slice(-MAX_MESSAGES_PER_SESSION),
80 80
       }));
81 81
     // Sort by updatedAt descending (most recent first)
82
-    return sessions.sort((a, b) => b.updatedAt - a.updatedAt);
82
+    return sessions.sort((a, b) => b.updatedAt - a.updatedAt).slice(0, MAX_SESSIONS);
83 83
   } catch (error) {
84 84
     console.warn('读取本地会话失败,将使用空会话列表', error);
85 85
     return [];

+ 65 - 95
src/stores/editorStore.ts

@@ -82,6 +82,35 @@ function isCanceledRequest(error: unknown): boolean {
82 82
   );
83 83
 }
84 84
 
85
+function serializeBlockForSave(block: DocumentBlock): Pick<BlockUpdate, 'content' | 'style'> {
86
+  if (block.type === 'table') {
87
+    const serializedTable = serializeTableBlock(block as TableBlock);
88
+    return {
89
+      content: serializedTable.content,
90
+      style: block.style,
91
+    };
92
+  }
93
+
94
+  if ((block.type === 'heading' || block.type === 'paragraph') && Array.isArray(block.content)) {
95
+    const firstStyle = block.content[0]?.style;
96
+    const allSameStyle = !!firstStyle && block.content.every((segment) =>
97
+      JSON.stringify(segment.style) === JSON.stringify(firstStyle),
98
+    );
99
+
100
+    return {
101
+      content: block.content.map((segment) => segment.text).join(''),
102
+      style: allSameStyle && Object.keys(firstStyle).length > 0
103
+        ? { ...block.style, ...firstStyle }
104
+        : block.style,
105
+    };
106
+  }
107
+
108
+  return {
109
+    content: block.content,
110
+    style: block.style,
111
+  };
112
+}
113
+
85 114
 // ══════════════════════════════════════════════════════════════════════════════
86 115
 // Store State Interface
87 116
 // ══════════════════════════════════════════════════════════════════════════════
@@ -582,34 +611,7 @@ export const useEditorStore = create<EditorStore>((set, get) => {
582 611
             // 获取该块的重试次数
583 612
             const attempts = retryAttempts.get(block.id) || 0;
584 613
             
585
-            // 序列化块内容(将富文本数组转为纯字符串,并提取样式到style字段)
586
-            let contentToSave = block.content;
587
-            let styleToSave = block.style;
588
-            
589
-            if (block.type === 'table') {
590
-              const serializedTable = serializeTableBlock(block as TableBlock);
591
-              contentToSave = serializedTable.content;
592
-            } else if (block.type === 'heading' || block.type === 'paragraph') {
593
-              // 对于标题和段落块,如果content是富文本数组,需要序列化
594
-              if (Array.isArray(block.content)) {
595
-                // 提取纯文本
596
-                contentToSave = block.content.map(seg => seg.text).join('');
597
-                
598
-                // 如果所有片段的样式一致,提取到块级style
599
-                const allSegments = block.content;
600
-                if (allSegments.length > 0) {
601
-                  const firstStyle = allSegments[0].style;
602
-                  const allSameStyle = allSegments.every(seg => 
603
-                    JSON.stringify(seg.style) === JSON.stringify(firstStyle)
604
-                  );
605
-                  
606
-                  if (allSameStyle && Object.keys(firstStyle).length > 0) {
607
-                    // 所有片段样式一致,合并到块级style
608
-                    styleToSave = { ...block.style, ...firstStyle };
609
-                  }
610
-                }
611
-              }
612
-            }
614
+            const { content: contentToSave, style: styleToSave } = serializeBlockForSave(block);
613 615
             
614 616
             try {
615 617
               const result = await blockService.updateBlock(
@@ -825,6 +827,9 @@ export const useEditorStore = create<EditorStore>((set, get) => {
825 827
         const blocksToRetry = blocks.filter(block => 
826 828
           failedBlocks.includes(block.id)
827 829
         );
830
+        const savedHashes = new Map(
831
+          blocksToRetry.map((block) => [block.id, computeBlockHash(block)]),
832
+        );
828 833
         
829 834
         const totalBlocks = blocksToRetry.length;
830 835
         
@@ -837,34 +842,7 @@ export const useEditorStore = create<EditorStore>((set, get) => {
837 842
         // 使用并发限制器重试保存
838 843
         const tasks = blocksToRetry.map(block => 
839 844
           saveConcurrencyLimit(() => {
840
-            // 序列化块内容(将富文本数组转为纯字符串,并提取样式到style字段)
841
-            let contentToSave = block.content;
842
-            let styleToSave = block.style;
843
-            
844
-            if (block.type === 'table') {
845
-              const serializedTable = serializeTableBlock(block as TableBlock);
846
-              contentToSave = serializedTable.content;
847
-            } else if (block.type === 'heading' || block.type === 'paragraph') {
848
-              // 对于标题和段落块,如果content是富文本数组,需要序列化
849
-              if (Array.isArray(block.content)) {
850
-                // 提取纯文本
851
-                contentToSave = block.content.map(seg => seg.text).join('');
852
-                
853
-                // 如果所有片段的样式一致,提取到块级style
854
-                const allSegments = block.content;
855
-                if (allSegments.length > 0) {
856
-                  const firstStyle = allSegments[0].style;
857
-                  const allSameStyle = allSegments.every(seg => 
858
-                    JSON.stringify(seg.style) === JSON.stringify(firstStyle)
859
-                  );
860
-                  
861
-                  if (allSameStyle && Object.keys(firstStyle).length > 0) {
862
-                    // 所有片段样式一致,合并到块级style
863
-                    styleToSave = { ...block.style, ...firstStyle };
864
-                  }
865
-                }
866
-              }
867
-            }
845
+            const { content: contentToSave, style: styleToSave } = serializeBlockForSave(block);
868 846
             
869 847
             return blockService.updateBlock(
870 848
               documentId, 
@@ -901,23 +879,20 @@ export const useEditorStore = create<EditorStore>((set, get) => {
901 879
           }
902 880
         });
903 881
         
904
-        // 从脏块集合中移除成功的块
905
-        const { dirtyBlocks } = get();
906
-        const newDirtyBlocks = new Set(dirtyBlocks);
907
-        successIds.forEach(id => newDirtyBlocks.delete(id));
882
+        const latestState = get();
883
+        const newDirtyBlocks = new Set(latestState.dirtyBlocks);
884
+        const newBlockHashes = new Map(latestState.blockHashes);
885
+        successIds.forEach(id => {
886
+          const latestBlock = latestState.blocks.find((block) => block.id === id);
887
+          const savedHash = savedHashes.get(id);
888
+          if (latestBlock && savedHash === computeBlockHash(latestBlock)) {
889
+            newDirtyBlocks.delete(id);
890
+            newBlockHashes.set(id, savedHash);
891
+          }
892
+        });
908 893
         
909 894
         if (stillFailedIds.length > 0) {
910 895
           // 仍有块保存失败
911
-          
912
-          // 更新成功保存的块的哈希值
913
-          const newBlockHashes = new Map(get().blockHashes);
914
-          successIds.forEach(id => {
915
-            const block = blocks.find(b => b.id === id);
916
-            if (block) {
917
-              newBlockHashes.set(id, computeBlockHash(block));
918
-            }
919
-          });
920
-          
921 896
           set({ 
922 897
             isSaving: false,
923 898
             failedBlocks: stillFailedIds,
@@ -932,28 +907,23 @@ export const useEditorStore = create<EditorStore>((set, get) => {
932 907
           throw new Error(`${stillFailedIds.length} 个块保存失败`);
933 908
         } else {
934 909
           // 全部重试成功
935
-          const snapshot = JSON.stringify(blocks);
936
-          
937
-          // 更新所有成功保存的块的哈希值
938
-          const newBlockHashes = new Map(get().blockHashes);
939
-          successIds.forEach(id => {
940
-            const block = blocks.find(b => b.id === id);
941
-            if (block) {
942
-              newBlockHashes.set(id, computeBlockHash(block));
910
+          const latestBlocks = get().blocks;
911
+          const snapshots = newDirtyBlocks.size === 0
912
+            ? {
913
+              originalBlocksSnapshot: JSON.stringify(latestBlocks),
914
+              lastSavedSnapshot: JSON.stringify(latestBlocks),
943 915
             }
944
-          });
945
-          
916
+            : {};
946 917
           set({ 
947 918
             isSaving: false,
948 919
             hasModified: newDirtyBlocks.size > 0,
949
-            originalBlocksSnapshot: snapshot,
950
-            lastSavedSnapshot: snapshot,
951 920
             failedBlocks: [],
952 921
             dirtyBlocks: newDirtyBlocks,
953
-            blockHashes: newBlockHashes, // 更新哈希表
922
+            blockHashes: newBlockHashes,
954 923
             currentSavePromise: null,
955 924
             saveAbortController: null,
956 925
             savingProgress: null,
926
+            ...snapshots,
957 927
           });
958 928
         }
959 929
       } catch (error: unknown) {
@@ -1183,14 +1153,14 @@ export const useEditorStore = create<EditorStore>((set, get) => {
1183 1153
       return;
1184 1154
     }
1185 1155
 
1186
-    set({ pendingStructuralOperations: pendingStructuralOperations + 1 });
1187
-    
1188 1156
     // 找到要删除的块
1189 1157
     const blockToDelete = blocks.find(b => b.id === id);
1190 1158
     if (!blockToDelete) {
1191 1159
       return;
1192 1160
     }
1193 1161
 
1162
+    set({ pendingStructuralOperations: pendingStructuralOperations + 1 });
1163
+
1194 1164
     const wasDirty = dirtyBlocks.has(id);
1195 1165
     
1196 1166
     // 先从本地状态删除(乐观更新)
@@ -1475,20 +1445,16 @@ export const useEditorStore = create<EditorStore>((set, get) => {
1475 1445
     }
1476 1446
     
1477 1447
     set({ isSaving: true, error: null });
1448
+    const savedHash = currentHash;
1478 1449
     
1479 1450
     try {
1480
-      // 序列化表格块的content(将富文本数组转为纯字符串)
1481
-      let contentToSave = block.content;
1482
-      if (block.type === 'table') {
1483
-        const serializedTable = serializeTableBlock(block as TableBlock);
1484
-        contentToSave = serializedTable.content;
1485
-      }
1451
+      const { content: contentToSave, style: styleToSave } = serializeBlockForSave(block);
1486 1452
       
1487 1453
       await blockService.updateBlock(documentId, id, {
1488 1454
         type: block.type,
1489 1455
         level: block.level,
1490 1456
         content: contentToSave,
1491
-        style: block.style,
1457
+        style: styleToSave,
1492 1458
         word_style: block.word_style,
1493 1459
         metadata: block.metadata,
1494 1460
         block_order: block.block_order,
@@ -1500,13 +1466,17 @@ export const useEditorStore = create<EditorStore>((set, get) => {
1500 1466
       const latestBlock = latestBlocks.find(b => b.id === id);
1501 1467
       
1502 1468
       if (latestBlock) {
1503
-        // 保存成功后,从脏块集合中移除该块
1469
+        const latestHash = computeBlockHash(latestBlock);
1470
+        if (latestHash !== savedHash) {
1471
+          set({ isSaving: false });
1472
+          return;
1473
+        }
1474
+
1504 1475
         const newDirtyBlocks = new Set(latestDirtyBlocks);
1505 1476
         newDirtyBlocks.delete(id);
1506 1477
         
1507
-        // **关键修复**: 使用保存成功时的块内容计算哈希值
1508 1478
         const newBlockHashes = new Map(latestBlockHashes);
1509
-        newBlockHashes.set(id, computeBlockHash(latestBlock));
1479
+        newBlockHashes.set(id, savedHash);
1510 1480
         
1511 1481
         set({ 
1512 1482
           isSaving: false,

+ 18 - 0
src/utils/download.ts

@@ -1,5 +1,23 @@
1 1
 const UNSAFE_FILE_NAME_CHARS = /[\\/:*?"<>|]/g;
2 2
 
3
+export function isAllowedHttpUrl(value: string): boolean {
4
+  try {
5
+    const url = new URL(value, window.location.origin);
6
+    if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
7
+      return false;
8
+    }
9
+
10
+    if (!/^(https?:)?\/\//i.test(value)) {
11
+      return true;
12
+    }
13
+
14
+    const configuredBaseUrl = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
15
+    return url.origin === new URL(configuredBaseUrl).origin;
16
+  } catch {
17
+    return false;
18
+  }
19
+}
20
+
3 21
 export function safeFileName(fileName: string, fallback = 'download'): string {
4 22
   const normalized = Array.from(fileName, (character) =>
5 23
     character.charCodeAt(0) < 32 ? '_' : character