Huanyi 3 месяцев назад
Родитель
Сommit
0755a43ddc

+ 0 - 294
MIME类型检测说明.md

@@ -1,294 +0,0 @@
-# MIME 类型检测说明
-
-## 问题
-
-后端下载接口返回的 MIME 类型是 `application/octet-stream`,但 Clipboard API 只支持图片类型:
-
-```
-Failed to execute 'write' on 'Clipboard': 
-Type application/octet-stream not supported on write.
-```
-
-## 原因
-
-后端代码设置了通用的二进制流类型:
-```java
-response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE + "; charset=UTF-8");
-```
-
-Clipboard API 只接受以下图片 MIME 类型:
-- `image/png`
-- `image/jpeg`
-- `image/gif`
-- `image/bmp`
-- `image/webp`
-- `image/svg+xml`
-
-## 解决方案
-
-在前端通过读取文件头(Magic Number)来检测真实的图片类型,然后创建正确 MIME 类型的 Blob。
-
-### 实现代码
-
-```typescript
-// 1. 下载图片
-const blob = await downloadSignature(ossId);
-
-// 2. 检测图片真实类型
-let imageBlob = blob;
-let mimeType = blob.type;
-
-// 如果是 application/octet-stream,需要检测真实的图片类型
-if (mimeType === 'application/octet-stream' || !mimeType.startsWith('image/')) {
-  // 读取文件头
-  const arrayBuffer = await blob.arrayBuffer();
-  const uint8Array = new Uint8Array(arrayBuffer);
-  
-  // 检测文件头(Magic Number)
-  if (uint8Array[0] === 0xFF && uint8Array[1] === 0xD8 && uint8Array[2] === 0xFF) {
-    mimeType = 'image/jpeg';
-  } else if (uint8Array[0] === 0x89 && uint8Array[1] === 0x50 && uint8Array[2] === 0x4E && uint8Array[3] === 0x47) {
-    mimeType = 'image/png';
-  } else if (uint8Array[0] === 0x47 && uint8Array[1] === 0x49 && uint8Array[2] === 0x46) {
-    mimeType = 'image/gif';
-  } else if (uint8Array[0] === 0x42 && uint8Array[1] === 0x4D) {
-    mimeType = 'image/bmp';
-  } else if (uint8Array[0] === 0x52 && uint8Array[1] === 0x49 && uint8Array[2] === 0x46 && uint8Array[3] === 0x46) {
-    mimeType = 'image/webp';
-  } else {
-    // 默认使用 PNG
-    mimeType = 'image/png';
-  }
-  
-  // 创建新的 Blob,使用正确的 MIME 类型
-  imageBlob = new Blob([arrayBuffer], { type: mimeType });
-}
-
-// 3. 复制到剪贴板
-await navigator.clipboard.write([
-  new ClipboardItem({
-    [imageBlob.type]: imageBlob
-  })
-]);
-```
-
-## 文件头(Magic Number)对照表
-
-| 格式 | 文件头(十六进制) | 说明 |
-|------|-------------------|------|
-| JPEG | `FF D8 FF` | JPEG 图片 |
-| PNG | `89 50 4E 47` | PNG 图片(\x89PNG) |
-| GIF | `47 49 46` | GIF 图片(GIF) |
-| BMP | `42 4D` | BMP 图片(BM) |
-| WebP | `52 49 46 46` | WebP 图片(RIFF) |
-| SVG | `3C 73 76 67` | SVG 图片(<svg) |
-
-## 工作流程
-
-```
-下载图片 Blob
-    ↓
-检查 MIME 类型
-    ↓
-是 application/octet-stream?
-    ├─ 是 → 读取文件头
-    │        ↓
-    │      检测图片类型
-    │        ↓
-    │      创建新 Blob(正确的 MIME 类型)
-    │        ↓
-    └─ 否 → 直接使用原 Blob
-    ↓
-复制到剪贴板
-```
-
-## 示例
-
-### JPEG 文件头检测
-
-```typescript
-const uint8Array = new Uint8Array(arrayBuffer);
-
-// JPEG 文件头:FF D8 FF
-if (uint8Array[0] === 0xFF && 
-    uint8Array[1] === 0xD8 && 
-    uint8Array[2] === 0xFF) {
-  mimeType = 'image/jpeg';
-}
-```
-
-### PNG 文件头检测
-
-```typescript
-// PNG 文件头:89 50 4E 47(\x89PNG)
-if (uint8Array[0] === 0x89 && 
-    uint8Array[1] === 0x50 && 
-    uint8Array[2] === 0x4E && 
-    uint8Array[3] === 0x47) {
-  mimeType = 'image/png';
-}
-```
-
-### GIF 文件头检测
-
-```typescript
-// GIF 文件头:47 49 46(GIF)
-if (uint8Array[0] === 0x47 && 
-    uint8Array[1] === 0x49 && 
-    uint8Array[2] === 0x46) {
-  mimeType = 'image/gif';
-}
-```
-
-## 调试日志
-
-```typescript
-console.log('[签名] 图片下载成功,大小:', blob.size, '原始类型:', blob.type);
-console.log('[签名] 检测到非图片 MIME 类型,尝试转换');
-console.log('[签名] 检测到的图片类型:', mimeType);
-console.log('[签名] 最终 MIME 类型:', imageBlob.type);
-```
-
-**输出示例**:
-```
-[签名] 图片下载成功,大小: 12345 原始类型: application/octet-stream
-[签名] 检测到非图片 MIME 类型,尝试转换
-[签名] 检测到的图片类型: image/png
-[签名] 最终 MIME 类型: image/png
-```
-
-## 优化建议
-
-### 前端方案(当前实现)
-✅ 优点:
-- 无需修改后端
-- 兼容性好
-- 自动检测图片类型
-
-⚠️ 缺点:
-- 需要读取整个文件到内存
-- 增加前端处理逻辑
-
-### 后端方案(推荐)
-修改后端代码,返回正确的 MIME 类型:
-
-```java
-@Override
-public void download(Long ossId, HttpServletResponse response) throws IOException {
-    SysOssVo sysOss = SpringUtils.getAopProxy(this).getById(ossId);
-    if (ObjectUtil.isNull(sysOss)) {
-        throw new ServiceException("文件数据不存在!");
-    }
-    
-    // 根据文件扩展名设置正确的 Content-Type
-    String fileName = sysOss.getOriginalName();
-    String contentType = getContentTypeByFileName(fileName);
-    
-    FileUtils.setAttachmentResponseHeader(response, fileName);
-    response.setContentType(contentType); // ← 使用正确的 MIME 类型
-    
-    OssClient storage = OssFactory.instance(sysOss.getService());
-    storage.download(sysOss.getFileName(), response.getOutputStream(), response::setContentLengthLong);
-}
-
-private String getContentTypeByFileName(String fileName) {
-    String ext = fileName.substring(fileName.lastIndexOf(".") + 1).toLowerCase();
-    switch (ext) {
-        case "jpg":
-        case "jpeg":
-            return "image/jpeg";
-        case "png":
-            return "image/png";
-        case "gif":
-            return "image/gif";
-        case "bmp":
-            return "image/bmp";
-        case "webp":
-            return "image/webp";
-        case "svg":
-            return "image/svg+xml";
-        default:
-            return MediaType.APPLICATION_OCTET_STREAM_VALUE;
-    }
-}
-```
-
-## 测试
-
-### 测试用例 1:JPEG 图片
-```typescript
-// 文件头:FF D8 FF
-// 预期:检测为 image/jpeg
-```
-
-### 测试用例 2:PNG 图片
-```typescript
-// 文件头:89 50 4E 47
-// 预期:检测为 image/png
-```
-
-### 测试用例 3:GIF 图片
-```typescript
-// 文件头:47 49 46
-// 预期:检测为 image/gif
-```
-
-### 测试用例 4:未知格式
-```typescript
-// 文件头:不匹配任何已知格式
-// 预期:默认使用 image/png
-```
-
-## 浏览器兼容性
-
-| 浏览器 | ArrayBuffer | Uint8Array | Clipboard API | 支持 |
-|--------|-------------|------------|---------------|------|
-| Chrome 90+ | ✅ | ✅ | ✅ | ✅ |
-| Edge 90+ | ✅ | ✅ | ✅ | ✅ |
-| Firefox 88+ | ✅ | ✅ | ✅ | ✅ |
-| Safari 14+ | ✅ | ✅ | ⚠️ | ⚠️ |
-
-## 性能考虑
-
-### 内存使用
-```typescript
-// 读取整个文件到内存
-const arrayBuffer = await blob.arrayBuffer(); // ← 占用内存
-
-// 只读取前几个字节(优化方案)
-const slice = blob.slice(0, 8); // 只读取前 8 字节
-const arrayBuffer = await slice.arrayBuffer();
-```
-
-### 优化版本
-
-```typescript
-// 只读取文件头(前 8 字节)
-const headerBlob = blob.slice(0, 8);
-const arrayBuffer = await headerBlob.arrayBuffer();
-const uint8Array = new Uint8Array(arrayBuffer);
-
-// 检测类型...
-
-// 使用原始 Blob 创建新 Blob
-imageBlob = new Blob([blob], { type: mimeType });
-```
-
-## 总结
-
-### 问题
-- 后端返回 `application/octet-stream`
-- Clipboard API 不支持此类型
-
-### 解决
-- 读取文件头检测真实图片类型
-- 创建正确 MIME 类型的 Blob
-- 成功复制到剪贴板
-
-### 优势
-- ✅ 无需修改后端
-- ✅ 自动检测图片类型
-- ✅ 兼容所有图片格式
-- ✅ 完善的错误处理
-
-现在用户可以成功复制签名图片到剪贴板了!

+ 0 - 424
WPS强制刷新文档说明.md

@@ -1,424 +0,0 @@
-# WPS 强制刷新文档说明
-
-## 问题
-
-点击"结束编辑"后,重新打开文件,WPS 仍然显示缓存的旧版本,而不是从服务器重新获取最新文件。
-
-## 原因
-
-### WPS 缓存机制
-
-WPS 使用 `fileId` 作为文档的唯一标识:
-
-```typescript
-const config = {
-  appId: 'SX20251229FLIAPD',
-  fileId: '28',  // ← 文档 ID
-  officeType: 'f'
-};
-```
-
-**问题**:
-- 相同的 `fileId` 会使用缓存的文档
-- 即使调用 `destroy()` 销毁实例
-- WPS 服务器仍然保留该 `fileId` 的文档
-- 重新初始化时会加载缓存版本
-
-### 缓存流程
-
-```
-第一次打开:
-fileId: "28"
-    ↓
-WPS 从你的服务器获取文件
-    ↓
-WPS 服务器缓存文件
-    ↓
-显示文档
-
-点击"结束编辑":
-调用 destroy()
-    ↓
-销毁前端实例
-    ↓
-但 WPS 服务器仍保留缓存
-
-重新打开:
-fileId: "28"  ← 相同的 ID
-    ↓
-WPS 发现缓存存在
-    ↓
-直接使用缓存(不从你的服务器获取)
-    ↓
-显示旧版本 ❌
-```
-
-## 解决方案
-
-### 方案 1:添加时间戳到 fileId(推荐)
-
-每次初始化时使用不同的 `fileId`,强制 WPS 重新获取文件:
-
-```typescript
-// 生成唯一的 fileId
-const timestamp = Date.now();
-const fileId = `${props.document.id}_${timestamp}`;
-
-const config = {
-  appId: WPS_APP_ID,
-  officeType: officeType,
-  fileId: fileId,  // ← 每次都不同
-  mount: wpsContainerRef.value
-};
-```
-
-**优势**:
-- ✅ 每次打开都是新的 `fileId`
-- ✅ WPS 会重新从服务器获取文件
-- ✅ 不会使用缓存
-- ✅ 简单可靠
-
-**示例**:
-```
-第一次打开:fileId = "28_1735545600000"
-第二次打开:fileId = "28_1735545700000"
-第三次打开:fileId = "28_1735545800000"
-```
-
-### 方案 2:添加 customArgs 参数
-
-在配置中添加自定义参数,提示 WPS 刷新:
-
-```typescript
-const config = {
-  appId: WPS_APP_ID,
-  officeType: officeType,
-  fileId: fileId,
-  mount: wpsContainerRef.value,
-  
-  // 添加自定义参数
-  customArgs: {
-    timestamp: Date.now(),
-    refresh: true
-  }
-};
-```
-
-**说明**:
-- `customArgs` 会传递给后端回调接口
-- 后端可以根据这些参数决定是否返回新文件
-- 但 WPS 可能仍然使用缓存
-
-### 方案 3:后端接口添加版本号
-
-在后端的文件信息接口中返回版本号:
-
-```json
-{
-  "file": {
-    "id": "28",
-    "name": "文档.pdf",
-    "version": "1735545600000",  // ← 版本号
-    "download_url": "http://..."
-  }
-}
-```
-
-WPS 会根据版本号判断是否需要重新下载。
-
-## 实现代码
-
-### 当前实现(方案 1 + 方案 2)
-
-```typescript
-// 初始化 WPS 编辑器
-const initWpsEditor = async () => {
-  if (!wpsContainerRef.value || !props.document?.ossId) {
-    return;
-  }
-
-  try {
-    wpsLoading.value = true;
-    wpsError.value = '';
-
-    const WebOfficeSDK = (window as any).WebOfficeSDK;
-    const officeType = getFileType(props.document.fileName || '');
-
-    // 生成唯一的 fileId(添加时间戳)
-    const timestamp = Date.now();
-    const fileId = `${props.document.id}_${timestamp}`;
-
-    console.log('[WPS] 初始化配置:', {
-      appId: WPS_APP_ID,
-      officeType: officeType,
-      fileId: fileId,
-      fileName: props.document.fileName,
-      timestamp: timestamp
-    });
-
-    // 初始化配置
-    const config = {
-      appId: WPS_APP_ID,
-      officeType: officeType,
-      fileId: fileId,  // ← 每次都不同
-      mount: wpsContainerRef.value,
-      
-      // 添加自定义参数
-      customArgs: {
-        timestamp: timestamp,
-        refresh: true
-      }
-    };
-
-    // 初始化编辑器
-    wpsInstance = WebOfficeSDK.init(config);
-
-    wpsLoading.value = false;
-    console.log('[WPS] 编辑器初始化成功');
-  } catch (err) {
-    console.error('[WPS] 初始化失败:', err);
-    wpsError.value = err.message || '初始化失败';
-    wpsLoading.value = false;
-  }
-};
-```
-
-## 工作流程
-
-### 优化前
-
-```
-第一次打开:
-fileId: "28"
-    ↓
-WPS 从服务器获取文件
-    ↓
-显示文档
-
-结束编辑:
-destroy()
-    ↓
-销毁实例
-
-重新打开:
-fileId: "28"  ← 相同
-    ↓
-WPS 使用缓存 ❌
-    ↓
-显示旧版本
-```
-
-### 优化后
-
-```
-第一次打开:
-fileId: "28_1735545600000"
-    ↓
-WPS 从服务器获取文件
-    ↓
-显示文档
-
-结束编辑:
-destroy()
-    ↓
-销毁实例
-
-重新打开:
-fileId: "28_1735545700000"  ← 不同
-    ↓
-WPS 重新从服务器获取 ✅
-    ↓
-显示最新版本
-```
-
-## 后端接口影响
-
-### 文件信息接口
-
-WPS 会调用你的后端接口获取文件信息:
-
-```
-GET /v1/3rd/file/info?_w_appid=xxx&_w_fileid=28_1735545600000
-```
-
-**注意**:
-- `_w_fileid` 参数现在包含时间戳
-- 后端需要解析 `fileId`,提取真实的文档 ID
-
-**后端实现建议**:
-
-```java
-@GetMapping("/v1/3rd/file/info")
-public R<FileInfo> getFileInfo(@RequestParam("_w_fileid") String fileId) {
-    // 解析 fileId,提取真实的文档 ID
-    String realDocId = fileId.split("_")[0];  // "28_1735545600000" -> "28"
-    
-    // 根据真实 ID 获取文档信息
-    Document doc = documentService.getById(realDocId);
-    
-    // 返回文件信息
-    return R.ok(buildFileInfo(doc));
-}
-```
-
-### 文件下载接口
-
-```
-GET /resource/oss/downloadWithoutPermission/{ossId}
-```
-
-**不受影响**:
-- 下载接口使用 `ossId`,不是 `fileId`
-- 无需修改
-
-## 调试日志
-
-### 优化前
-
-```
-[WPS] 初始化配置: {
-  appId: "SX20251229FLIAPD",
-  officeType: "f",
-  fileId: "28",
-  fileName: "文档.pdf"
-}
-```
-
-### 优化后
-
-```
-[WPS] 初始化配置: {
-  appId: "SX20251229FLIAPD",
-  officeType: "f",
-  fileId: "28_1735545600000",
-  fileName: "文档.pdf",
-  timestamp: 1735545600000
-}
-```
-
-## 测试场景
-
-### 场景 1:正常打开
-```
-操作:打开文档
-预期:fileId = "28_[当前时间戳]"
-结果:WPS 从服务器获取文件
-```
-
-### 场景 2:结束编辑后重新打开
-```
-操作:
-1. 打开文档(fileId = "28_1735545600000")
-2. 点击"结束编辑"
-3. 重新打开文档(fileId = "28_1735545700000")
-
-预期:两次的 fileId 不同
-结果:WPS 重新从服务器获取文件
-```
-
-### 场景 3:快速重复打开
-```
-操作:
-1. 打开文档
-2. 立即关闭
-3. 立即重新打开
-
-预期:两次的 fileId 不同(时间戳不同)
-结果:WPS 重新从服务器获取文件
-```
-
-### 场景 4:同一文档多次打开
-```
-操作:
-1. 打开文档 A(fileId = "28_1735545600000")
-2. 关闭
-3. 打开文档 B(fileId = "29_1735545700000")
-4. 关闭
-5. 再次打开文档 A(fileId = "28_1735545800000")
-
-预期:每次的 fileId 都不同
-结果:每次都从服务器获取最新文件
-```
-
-## 性能考虑
-
-### 优势
-- ✅ 每次都获取最新文件
-- ✅ 不会显示旧版本
-- ✅ 用户体验好
-
-### 劣势
-- ⚠️ 每次都需要下载文件(不使用缓存)
-- ⚠️ 增加服务器负载
-- ⚠️ 增加网络流量
-
-### 优化建议
-
-如果文件很大,可以考虑:
-
-1. **使用版本号**:
-   ```typescript
-   const fileId = `${props.document.id}_v${props.document.version}`;
-   ```
-   只有版本变化时才重新下载。
-
-2. **使用哈希值**:
-   ```typescript
-   const fileId = `${props.document.id}_${props.document.hash}`;
-   ```
-   文件内容变化时哈希值才变化。
-
-3. **添加缓存控制**:
-   ```typescript
-   const config = {
-     // ...
-     customArgs: {
-       cache: props.document.allowCache ? 'true' : 'false'
-     }
-   };
-   ```
-
-## 常见问题
-
-### Q1: 为什么要添加时间戳?
-**答**:让每次打开的 `fileId` 都不同,强制 WPS 重新从服务器获取文件。
-
-### Q2: 时间戳会影响后端接口吗?
-**答**:会,后端需要解析 `fileId`,提取真实的文档 ID。
-
-### Q3: 可以使用其他方式吗?
-**答**:可以,比如版本号、哈希值、随机数等,只要保证每次不同即可。
-
-### Q4: 会增加服务器负载吗?
-**答**:会,因为每次都需要下载文件。如果文件很大,建议使用版本号方案。
-
-### Q5: 旧的 fileId 会被清理吗?
-**答**:WPS 服务器会定期清理过期的文档缓存。
-
-### Q6: 可以手动清理缓存吗?
-**答**:可以,调用 `destroy()` 方法会清理前端实例,但 WPS 服务器的缓存由 WPS 管理。
-
-## 总结
-
-### 核心改动
-```typescript
-// 优化前
-const fileId = `${props.document.id}`;
-
-// 优化后
-const timestamp = Date.now();
-const fileId = `${props.document.id}_${timestamp}`;
-```
-
-### 效果
-- ✅ 每次打开都是新的 `fileId`
-- ✅ WPS 重新从服务器获取文件
-- ✅ 不会显示缓存的旧版本
-- ✅ 用户看到的始终是最新文件
-
-### 后端注意事项
-- ⚠️ 需要解析 `fileId`,提取真实的文档 ID
-- ⚠️ `fileId` 格式:`{documentId}_{timestamp}`
-- ⚠️ 示例:`"28_1735545600000"` → 文档 ID 是 `"28"`
-
-现在重新打开文件时,WPS 会从你的服务器重新获取最新文件,而不是使用缓存的旧版本!

+ 0 - 446
WPS文档版本控制方案.md

@@ -1,446 +0,0 @@
-# WPS 文档版本控制方案
-
-## 需求
-
-在 fileId 不变的情况下,让 WPS 重新从服务器获取文件。
-
-## 解决方案
-
-通过后端的文件信息接口返回不同的版本号,让 WPS 判断文件已更新,从而重新下载。
-
-## 工作原理
-
-### WPS 缓存判断机制
-
-WPS 通过以下字段判断是否需要重新下载文件:
-
-1. **version**(版本号)
-2. **modify_time**(修改时间)
-3. **size**(文件大小)
-
-如果这些字段发生变化,WPS 会重新下载文件。
-
-### 流程
-
-```
-用户打开文档:
-    ↓
-WPS 调用:GET /v1/3rd/file/info?_w_fileid=28
-    ↓
-后端返回:
-{
-  "file": {
-    "id": "28",
-    "name": "文档.pdf",
-    "version": 1,  // ← 版本号
-    "modify_time": 1735545600000,
-    "size": 1024000
-  }
-}
-    ↓
-WPS 检查缓存:
-  - 缓存中的版本号是 1
-  - 服务器返回的版本号是 1
-  - 版本号相同,使用缓存 ✅
-
-用户点击"结束编辑":
-    ↓
-前端调用:PUT /wps/callback/v3/3rd/file/28/version
-    ↓
-后端更新版本号:version = 2
-    ↓
-销毁 WPS 实例
-
-用户重新打开文档:
-    ↓
-WPS 调用:GET /v1/3rd/file/info?_w_fileid=28
-    ↓
-后端返回:
-{
-  "file": {
-    "id": "28",
-    "name": "文档.pdf",
-    "version": 2,  // ← 版本号已更新
-    "modify_time": 1735545700000,
-    "size": 1024000
-  }
-}
-    ↓
-WPS 检查缓存:
-  - 缓存中的版本号是 1
-  - 服务器返回的版本号是 2
-  - 版本号不同,重新下载 ✅
-```
-
-## 前端实现
-
-### 1. 新增 API 接口
-
-**文件**:`src/api/system/signature.ts`
-
-```typescript
-/**
- * 通知后端更新文档版本(用于强制 WPS 刷新)
- * @param documentId 文档 ID
- */
-export function updateDocumentVersion(documentId: number | string): AxiosPromise<any> {
-    return request({
-        url: `/wps/callback/v3/3rd/file/${documentId}/version`,
-        method: 'put'
-    });
-}
-```
-
-### 2. 修改"结束编辑"功能
-
-**文件**:`src/components/DocumentAuditDialog/index.vue`
-
-```typescript
-const handleEndEdit = async () => {
-  // ... 确认对话框 ...
-  
-  try {
-    // 1. 调用 WPS SDK 的销毁方法
-    if (wpsInstance.destroy) {
-      await wpsInstance.destroy();
-    }
-    
-    // 2. 通知后端更新文档版本
-    try {
-      await updateDocumentVersion(props.document.id);
-      console.log('[WPS] 文档版本已更新');
-    } catch (versionErr) {
-      console.warn('[WPS] 更新文档版本失败(不影响主流程):', versionErr);
-    }
-    
-    // 3. 清空实例
-    wpsInstance = null;
-    
-    // 4. 显示成功提示并关闭对话框
-    ElMessage.success('编辑已结束,文档已销毁');
-    setTimeout(() => {
-      dialogVisible.value = false;
-    }, 1000);
-    
-  } catch (err) {
-    // 错误处理...
-  }
-};
-```
-
-## 后端实现
-
-### 1. 数据库表结构
-
-需要在文档表中添加版本号字段:
-
-```sql
-ALTER TABLE document ADD COLUMN version INT DEFAULT 1;
-```
-
-或者使用修改时间:
-
-```sql
-ALTER TABLE document ADD COLUMN modify_time BIGINT;
-```
-
-### 2. 更新版本接口
-
-**接口地址**:`PUT /wps/callback/v3/3rd/file/{documentId}/version`
-
-**功能**:更新文档版本号
-
-**实现示例**:
-
-```java
-@PutMapping("/wps/callback/v3/3rd/file/{documentId}/version")
-public R<Void> updateDocumentVersion(@PathVariable Long documentId) {
-    // 1. 获取文档
-    Document doc = documentService.getById(documentId);
-    if (doc == null) {
-        return R.fail("文档不存在");
-    }
-    
-    // 2. 更新版本号
-    doc.setVersion(doc.getVersion() + 1);
-    doc.setModifyTime(System.currentTimeMillis());
-    documentService.updateById(doc);
-    
-    log.info("文档版本已更新: id={}, version={}", documentId, doc.getVersion());
-    
-    return R.ok();
-}
-```
-
-### 3. 文件信息接口
-
-**接口地址**:`GET /v1/3rd/file/info`
-
-**修改**:返回版本号和修改时间
-
-**实现示例**:
-
-```java
-@GetMapping("/v1/3rd/file/info")
-public R<FileInfo> getFileInfo(@RequestParam("_w_fileid") String fileId) {
-    // 1. 获取文档
-    Document doc = documentService.getById(fileId);
-    if (doc == null) {
-        return R.fail("文档不存在");
-    }
-    
-    // 2. 构建文件信息
-    FileInfo fileInfo = new FileInfo();
-    fileInfo.setId(doc.getId().toString());
-    fileInfo.setName(doc.getFileName());
-    fileInfo.setVersion(doc.getVersion());  // ← 版本号
-    fileInfo.setModifyTime(doc.getModifyTime());  // ← 修改时间
-    fileInfo.setSize(doc.getFileSize());
-    fileInfo.setDownloadUrl(buildDownloadUrl(doc.getOssId()));
-    
-    return R.ok(fileInfo);
-}
-```
-
-**返回格式**:
-
-```json
-{
-  "code": 200,
-  "msg": "success",
-  "data": {
-    "file": {
-      "id": "28",
-      "name": "文档.pdf",
-      "version": 2,
-      "modify_time": 1735545700000,
-      "size": 1024000,
-      "download_url": "http://your-server.com/resource/oss/downloadWithoutPermission/123"
-    }
-  }
-}
-```
-
-## 完整流程
-
-### 第一次打开文档
-
-```
-1. 用户打开文档
-    ↓
-2. WPS 调用:GET /v1/3rd/file/info?_w_fileid=28
-    ↓
-3. 后端返回:version=1, modify_time=1735545600000
-    ↓
-4. WPS 缓存中没有该文档
-    ↓
-5. WPS 下载文件:GET /resource/oss/downloadWithoutPermission/123
-    ↓
-6. WPS 缓存文件(version=1)
-    ↓
-7. 显示文档
-```
-
-### 结束编辑
-
-```
-1. 用户点击"结束编辑"
-    ↓
-2. 前端调用:wpsInstance.destroy()
-    ↓
-3. 前端调用:PUT /wps/callback/v3/3rd/file/28/version
-    ↓
-4. 后端更新:version=2, modify_time=1735545700000
-    ↓
-5. 前端清空实例:wpsInstance = null
-    ↓
-6. 关闭对话框
-```
-
-### 重新打开文档
-
-```
-1. 用户重新打开文档
-    ↓
-2. WPS 调用:GET /v1/3rd/file/info?_w_fileid=28
-    ↓
-3. 后端返回:version=2, modify_time=1735545700000
-    ↓
-4. WPS 检查缓存:
-   - 缓存版本:version=1
-   - 服务器版本:version=2
-   - 版本不同!
-    ↓
-5. WPS 重新下载文件:GET /resource/oss/downloadWithoutPermission/123
-    ↓
-6. WPS 更新缓存(version=2)
-    ↓
-7. 显示最新文档 ✅
-```
-
-## 版本号策略
-
-### 方案 1:递增版本号(推荐)
-
-```java
-// 每次更新时递增
-doc.setVersion(doc.getVersion() + 1);
-```
-
-**优势**:
-- ✅ 简单明了
-- ✅ 易于理解
-- ✅ 便于调试
-
-**示例**:
-```
-初始:version = 1
-第一次更新:version = 2
-第二次更新:version = 3
-```
-
-### 方案 2:使用时间戳
-
-```java
-// 使用当前时间戳作为版本号
-doc.setVersion(System.currentTimeMillis());
-```
-
-**优势**:
-- ✅ 自动递增
-- ✅ 包含时间信息
-- ✅ 不会重复
-
-**示例**:
-```
-初始:version = 1735545600000
-第一次更新:version = 1735545700000
-第二次更新:version = 1735545800000
-```
-
-### 方案 3:使用修改时间
-
-```java
-// 只更新 modify_time,不使用 version
-doc.setModifyTime(System.currentTimeMillis());
-```
-
-**优势**:
-- ✅ 不需要额外字段
-- ✅ 符合语义
-- ✅ WPS 也会检查此字段
-
-**注意**:
-- WPS 会同时检查 `version` 和 `modify_time`
-- 任一字段变化都会触发重新下载
-
-## 调试日志
-
-### 前端日志
-
-```
-[WPS] 开始结束编辑,文档 ID: 28
-[WPS] 调用 destroy 方法
-[WPS] destroy 方法调用成功
-[WPS] 通知后端更新文档版本
-[WPS] 文档版本已更新
-[WPS] 结束编辑成功
-```
-
-### 后端日志
-
-```
-[INFO] 收到更新版本请求: documentId=28
-[INFO] 当前版本: 1
-[INFO] 更新后版本: 2
-[INFO] 文档版本已更新: id=28, version=2
-```
-
-### WPS 日志
-
-```
-[WPS] 获取文件信息: fileId=28
-[WPS] 服务器版本: 2
-[WPS] 缓存版本: 1
-[WPS] 版本不同,重新下载文件
-[WPS] 下载完成,更新缓存
-```
-
-## 测试场景
-
-### 场景 1:正常流程
-
-```
-1. 打开文档(version=1)
-2. 编辑文档
-3. 点击"结束编辑"(version 更新为 2)
-4. 重新打开文档
-5. 预期:WPS 重新下载文件
-```
-
-### 场景 2:多次编辑
-
-```
-1. 打开文档(version=1)
-2. 结束编辑(version=2)
-3. 重新打开(version=2)
-4. 结束编辑(version=3)
-5. 重新打开(version=3)
-6. 预期:每次都重新下载
-```
-
-### 场景 3:版本更新失败
-
-```
-1. 打开文档(version=1)
-2. 点击"结束编辑"
-3. 版本更新接口失败(version 仍为 1)
-4. 重新打开文档
-5. 预期:WPS 使用缓存(因为版本号未变)
-```
-
-## 常见问题
-
-### Q1: 为什么要更新版本号?
-**答**:让 WPS 知道文件已更新,需要重新下载。
-
-### Q2: 版本号必须递增吗?
-**答**:不必须,只要每次不同即可。但递增更易于理解和调试。
-
-### Q3: 如果版本更新失败怎么办?
-**答**:不影响主流程,只是下次打开时 WPS 会使用缓存。
-
-### Q4: 可以手动触发刷新吗?
-**答**:可以,调用版本更新接口即可。
-
-### Q5: 版本号会一直增长吗?
-**答**:是的,但这不是问题。可以定期重置或使用时间戳。
-
-### Q6: 需要修改数据库吗?
-**答**:是的,需要添加 `version` 字段或使用 `modify_time` 字段。
-
-## 总结
-
-### 核心思路
-- ✅ fileId 保持不变
-- ✅ 通过版本号控制缓存
-- ✅ 结束编辑时更新版本号
-- ✅ 重新打开时 WPS 检测到版本变化
-- ✅ WPS 重新下载文件
-
-### 前端改动
-1. 新增 `updateDocumentVersion` API
-2. 在"结束编辑"时调用该 API
-
-### 后端改动
-1. 添加 `version` 字段到数据库
-2. 实现版本更新接口
-3. 在文件信息接口中返回版本号
-
-### 优势
-- ✅ fileId 不变,后端逻辑简单
-- ✅ 符合 WPS 的缓存机制
-- ✅ 可控性强
-- ✅ 易于调试
-
-现在点击"结束编辑"后,重新打开文档时 WPS 会从你的服务器重新获取最新文件!

+ 0 - 0
WPS缓存问题说明.md


+ 0 - 473
结束编辑功能说明.md

@@ -1,473 +0,0 @@
-# 结束编辑功能说明
-
-## 功能概述
-
-添加"结束编辑"按钮,点击后调用 WPS SDK 的 `destroy()` 方法销毁文档,释放 WPS 服务器资源。
-
-## 功能特点
-
-### 1. 一键结束
-- 点击"结束编辑"按钮
-- 确认后自动销毁 WPS 文档
-- 关闭编辑对话框
-
-### 2. 安全确认
-在执行前弹出确认对话框:
-```
-⚠️ 结束编辑
-
-确定要结束编辑吗?这将销毁 WPS 文档,未保存的修改将丢失。
-
-[取消]  [确定]
-```
-
-### 3. 自动清理
-- 调用 WPS SDK 的 `destroy()` 方法
-- 清空 `wpsInstance` 实例
-- 关闭编辑对话框
-- 释放 WPS 服务器资源
-
-## 技术实现
-
-### 核心代码
-
-```typescript
-const handleEndEdit = async () => {
-  if (!wpsInstance) {
-    ElMessage.warning('WPS 编辑器未初始化');
-    return;
-  }
-  
-  // 1. 确认对话框
-  await ElMessageBox.confirm(
-    '确定要结束编辑吗?这将销毁 WPS 文档,未保存的修改将丢失。',
-    '结束编辑',
-    {
-      confirmButtonText: '确定',
-      cancelButtonText: '取消',
-      type: 'warning'
-    }
-  );
-  
-  try {
-    endingEdit.value = true;
-    
-    // 2. 显示加载提示
-    const loadingMsg = ElMessage({
-      message: '正在结束编辑...',
-      duration: 0,
-      showClose: false
-    });
-    
-    // 3. 调用 WPS SDK 的销毁方法
-    if (wpsInstance.destroy) {
-      await wpsInstance.destroy();
-    }
-    
-    // 4. 清空实例
-    wpsInstance = null;
-    
-    // 5. 关闭加载提示
-    loadingMsg.close();
-    
-    // 6. 显示成功提示
-    ElMessage.success('编辑已结束,文档已销毁');
-    
-    // 7. 关闭对话框
-    setTimeout(() => {
-      dialogVisible.value = false;
-    }, 1000);
-    
-  } catch (err) {
-    // 错误处理...
-  } finally {
-    endingEdit.value = false;
-  }
-};
-```
-
-## 工作流程
-
-```
-用户点击"结束编辑"
-    ↓
-显示确认对话框
-    ↓
-用户确认
-    ↓
-显示加载提示:"正在结束编辑..."
-    ↓
-调用 wpsInstance.destroy()
-    ↓
-WPS 销毁文档(释放服务器资源)
-    ↓
-清空 wpsInstance = null
-    ↓
-关闭加载提示
-    ↓
-显示成功提示:"编辑已结束,文档已销毁"
-    ↓
-1 秒后关闭对话框
-    ↓
-完成!
-```
-
-## 界面变化
-
-### 修改前
-```
-┌─────────────────────────────────────┐
-│ 📄 文档名称.docx                    │
-│                                     │
-│ [🖼️ 复制签名]                       │
-└─────────────────────────────────────┘
-```
-
-### 修改后
-```
-┌─────────────────────────────────────┐
-│ 📄 文档名称.docx                    │
-│                                     │
-│ [🖼️ 复制签名] [⭕ 结束编辑]        │
-└─────────────────────────────────────┘
-```
-
-## WPS SDK destroy() 方法
-
-### 方法说明
-
-```typescript
-wpsInstance.destroy(): Promise<void>
-```
-
-**功能**:
-- 销毁 WPS 文档实例
-- 释放 WPS 服务器资源
-- 清理临时文件
-- 断开与 WPS 服务器的连接
-
-**返回**:
-- Promise,成功时 resolve,失败时 reject
-
-### 调用示例
-
-```typescript
-// 基本用法
-await wpsInstance.destroy();
-
-// 带错误处理
-try {
-  await wpsInstance.destroy();
-  console.log('文档已销毁');
-} catch (err) {
-  console.error('销毁失败:', err);
-}
-
-// 检查方法是否存在
-if (wpsInstance.destroy) {
-  await wpsInstance.destroy();
-} else {
-  console.warn('destroy 方法不存在');
-}
-```
-
-## 使用场景
-
-### 场景 1:审核完成后结束编辑
-```
-审核文档
-    ↓
-提交审核结果
-    ↓
-点击"结束编辑"
-    ↓
-销毁 WPS 文档
-    ↓
-关闭对话框
-```
-
-### 场景 2:取消编辑
-```
-打开文档编辑
-    ↓
-发现不需要编辑
-    ↓
-点击"结束编辑"
-    ↓
-销毁 WPS 文档
-    ↓
-关闭对话框
-```
-
-### 场景 3:释放资源
-```
-长时间编辑
-    ↓
-暂时不需要编辑
-    ↓
-点击"结束编辑"
-    ↓
-释放 WPS 服务器资源
-    ↓
-需要时重新打开
-```
-
-## 确认对话框
-
-```
-⚠️ 结束编辑
-
-确定要结束编辑吗?这将销毁 WPS 文档,未保存的修改将丢失。
-
-[取消]  [确定]
-```
-
-**重要提示**:
-- 销毁文档后无法恢复
-- 未保存的修改将丢失
-- 建议先保存文档
-
-## 加载提示
-
-```
-ℹ️ 正在结束编辑...
-```
-
-**显示时机**:
-- 点击确定后立即显示
-- 持续显示直到操作完成
-- 不自动关闭
-- 不显示关闭按钮
-
-## 成功提示
-
-```
-✅ 编辑已结束,文档已销毁
-```
-
-**显示时机**:
-- 操作成功完成后
-- 自动关闭(3秒)
-- 1秒后关闭对话框
-
-## 错误处理
-
-### 1. 编辑器未初始化
-```typescript
-if (!wpsInstance) {
-  ElMessage.warning('WPS 编辑器未初始化');
-  return;
-}
-```
-
-### 2. destroy 方法不存在
-```typescript
-if (wpsInstance.destroy) {
-  await wpsInstance.destroy();
-} else {
-  console.warn('destroy 方法不存在');
-}
-```
-
-### 3. 销毁失败
-```typescript
-catch (err) {
-  console.error('[WPS] 结束编辑失败:', err);
-  
-  // 即使失败也尝试清空实例
-  wpsInstance = null;
-  
-  ElMessage.warning('结束编辑可能未完全成功: ' + err.message);
-  
-  // 仍然关闭对话框
-  setTimeout(() => {
-    dialogVisible.value = false;
-  }, 1000);
-}
-```
-
-### 4. 用户取消
-```typescript
-try {
-  await ElMessageBox.confirm(...);
-} catch {
-  // 用户点击取消,直接返回
-  return;
-}
-```
-
-## 与关闭对话框的区别
-
-### 关闭对话框(点击 X 或取消)
-```typescript
-watch(dialogVisible, (val) => {
-  if (!val) {
-    // 销毁编辑器
-    destroyWpsEditor();
-  }
-});
-```
-
-**行为**:
-- 调用 `destroyWpsEditor()` 函数
-- 清理前端编辑器实例
-- 但 WPS 服务器可能仍保留文档
-
-### 结束编辑(点击"结束编辑"按钮)
-```typescript
-const handleEndEdit = async () => {
-  // 调用 WPS SDK 的 destroy 方法
-  await wpsInstance.destroy();
-  
-  // 清空实例
-  wpsInstance = null;
-  
-  // 关闭对话框
-  dialogVisible.value = false;
-};
-```
-
-**行为**:
-- 调用 WPS SDK 的 `destroy()` 方法
-- 通知 WPS 服务器销毁文档
-- 释放服务器资源
-- 清理临时文件
-- 然后关闭对话框
-
-## 性能考虑
-
-### 1. 操作时间
-```
-调用 destroy():1-2 秒
-清空实例:< 0.1 秒
-关闭对话框:1 秒延迟
-总计:2-3 秒
-```
-
-### 2. 资源释放
-- ✅ WPS 服务器资源
-- ✅ 临时文件
-- ✅ 网络连接
-- ✅ 内存占用
-
-### 3. 优化建议
-
-**减少延迟**:
-```typescript
-// 不需要等待 1 秒
-// setTimeout(() => {
-//   dialogVisible.value = false;
-// }, 1000);
-
-// 立即关闭
-dialogVisible.value = false;
-```
-
-**并行操作**:
-```typescript
-// 同时销毁和显示提示
-await Promise.all([
-  wpsInstance.destroy(),
-  ElMessage.success('编辑已结束')
-]);
-```
-
-## 调试日志
-
-```
-[WPS] 开始结束编辑
-[WPS] 调用 destroy 方法
-[WPS] destroy 方法调用成功
-[WPS] 结束编辑成功
-```
-
-**失败日志**:
-```
-[WPS] 开始结束编辑
-[WPS] 调用 destroy 方法
-[WPS] 结束编辑失败: Error: ...
-```
-
-## 常见问题
-
-### Q1: 结束编辑后还能重新打开吗?
-**答**:可以,重新打开对话框会重新初始化 WPS 编辑器。
-
-### Q2: 未保存的修改会丢失吗?
-**答**:是的,结束编辑会销毁文档,未保存的修改将丢失。
-
-### Q3: 结束编辑和关闭对话框有什么区别?
-**答**:
-- **结束编辑**:调用 WPS SDK 销毁文档,释放服务器资源
-- **关闭对话框**:只清理前端实例,服务器可能仍保留文档
-
-### Q4: 结束编辑需要多长时间?
-**答**:通常 2-3 秒,取决于网络速度。
-
-### Q5: 结束编辑失败怎么办?
-**答**:即使失败,也会清空前端实例并关闭对话框。
-
-### Q6: 可以撤销结束编辑吗?
-**答**:不可以,销毁后无法恢复。
-
-## 安全考虑
-
-### 1. 确认对话框
-防止误操作:
-```typescript
-await ElMessageBox.confirm(
-  '确定要结束编辑吗?这将销毁 WPS 文档,未保存的修改将丢失。',
-  '结束编辑',
-  { type: 'warning' }
-);
-```
-
-### 2. 状态检查
-确保编辑器已初始化:
-```typescript
-if (!wpsInstance) {
-  ElMessage.warning('WPS 编辑器未初始化');
-  return;
-}
-```
-
-### 3. 错误容忍
-即使失败也清理资源:
-```typescript
-catch (err) {
-  // 即使失败也尝试清空实例
-  wpsInstance = null;
-  
-  // 仍然关闭对话框
-  dialogVisible.value = false;
-}
-```
-
-## 总结
-
-### 核心功能
-1. ✅ **一键结束**:点击按钮即可
-2. ✅ **安全确认**:防止误操作
-3. ✅ **释放资源**:调用 WPS SDK 销毁文档
-4. ✅ **自动关闭**:操作完成后关闭对话框
-
-### 实现步骤
-```
-1. 确认操作
-2. 调用 wpsInstance.destroy()
-3. 清空实例
-4. 关闭对话框
-```
-
-### 用户操作
-```
-点击"结束编辑" → 确认 → 等待 2-3 秒 → 对话框关闭
-```
-
-### 优势
-- ✅ 释放 WPS 服务器资源
-- ✅ 清理临时文件
-- ✅ 防止资源泄漏
-- ✅ 用户体验好
-
-现在用户可以通过"结束编辑"按钮主动销毁 WPS 文档,释放服务器资源!