Browse Source

feat(system): 实现大文件分片上传和智能下载功能

- 增加分片上传API接口,支持初始化、上传、完成和取消分片上传
- 新增ChunkUpload组件,支持大文件分片上传和断点续传
- 扩展任务下载功能,增加预签名URL直接下载和智能下载策略
- 优化参赛证生成任务超时时间从5分钟延长至1小时
- 在任务列表页面增加刷新按钮和复制下载链接功能
- 实现smartDownloadTask方法,优先直接下载失败时回退到代理下载
- 添加getDownloadUrl接口获取OSS预签名下载地址
- 重构downloadTask逻辑,支持更可靠的文件下载流程
zhou 1 month ago
parent
commit
130600df87

+ 1 - 1
src/api/system/gameEvent/index.ts

@@ -189,7 +189,7 @@ export const generateBib = (bgImage: File, logo: File | null, bibParam: Generate
     // 必须设置 responseType 为 'blob' 才能触发文件下载
     responseType: 'blob',
     // 增加超时时间到5分钟,避免大文件生成超时
-    timeout: 300000
+    timeout: 3600000
   });
 };
 

+ 51 - 2
src/api/system/gameEvent/task.ts

@@ -9,7 +9,7 @@ export const createBibTask = (bgImage: File, logo: File | null, taskName: string
   }
   formData.append('taskName', taskName);
   formData.append('bibParam', new Blob([JSON.stringify(bibParam)], { type: 'application/json' }));
-  
+
   return request({
     url: '/system/number/createBibTask',
     method: 'post',
@@ -45,7 +45,43 @@ export const deleteTask = (taskId: number) => {
   });
 };
 
-// 下载任务结果
+// 获取预签名下载URL
+export const getDownloadUrl = (taskId: number) => {
+  return request({
+    url: `/system/number/getDownloadUrl/${taskId}`,
+    method: 'get'
+  });
+};
+
+// 直接下载任务结果(使用预签名URL)
+export const directDownloadTask = async (taskId: number) => {
+  try {
+    const response = await getDownloadUrl(taskId);
+    console.log('API响应:', response);
+
+    // 检查响应结构 - URL在msg字段中,不在data字段中
+    const downloadUrl = response.msg || response.data;
+    console.log('预签名URL:', downloadUrl);
+
+    // 检查URL是否有效
+    if (!downloadUrl || downloadUrl === 'null' || downloadUrl === '') {
+      throw new Error('预签名URL为空或无效');
+    }
+
+    // 直接使用预签名URL下载
+    console.log('使用预签名URL直接下载');
+
+    // 直接打开预签名URL
+    window.open(downloadUrl, '_self');
+
+    console.log('直接下载已触发');
+  } catch (error) {
+    console.error('直接下载失败:', error);
+    throw error;
+  }
+};
+
+// 下载任务结果(原有方式,作为回退)
 export const downloadTask = (taskId: number) => {
   return request({
     url: `/system/number/downloadTask/${taskId}`,
@@ -70,3 +106,16 @@ export const downloadTask = (taskId: number) => {
     throw error;
   });
 };
+
+// 智能下载(优先直接下载,失败时回退到代理下载)
+export const smartDownloadTask = async (taskId: number) => {
+  try {
+    // 优先尝试直接下载
+    console.log('尝试直接下载...');
+    await directDownloadTask(taskId);
+  } catch (error) {
+    console.warn('直接下载失败,回退到代理下载:', error);
+    // 回退到代理下载
+    await downloadTask(taskId);
+  }
+};

+ 70 - 0
src/api/system/oss/multipart.ts

@@ -0,0 +1,70 @@
+import request from '@/utils/request';
+
+// 初始化分片上传
+export function initMultipartUpload(fileName: string, fileSize: number) {
+  return request({
+    url: '/system/oss/multipart/init',
+    method: 'post',
+    params: {
+      fileName,
+      fileSize
+    }
+  });
+}
+
+// 上传分片
+export function uploadPart(uploadId: string, partNumber: number, formData: FormData) {
+  return request({
+    url: '/system/oss/multipart/uploadPart',
+    method: 'post',
+    data: formData,
+    params: {
+      uploadId,
+      partNumber
+    },
+    headers: {
+      'Content-Type': 'multipart/form-data'
+    }
+  });
+}
+
+// 完成分片上传
+export function completeMultipartUpload(
+  uploadId: string, 
+  partETags: string, 
+  fileName: string, 
+  originalName: string
+) {
+  return request({
+    url: '/system/oss/multipart/complete',
+    method: 'post',
+    params: {
+      uploadId,
+      partETags,
+      fileName,
+      originalName
+    }
+  });
+}
+
+// 取消分片上传
+export function abortMultipartUpload(uploadId: string) {
+  return request({
+    url: '/system/oss/multipart/abort',
+    method: 'post',
+    params: {
+      uploadId
+    }
+  });
+}
+
+// 检查已上传的分片
+export function checkUploadedParts(uploadId: string) {
+  return request({
+    url: '/system/oss/multipart/checkParts',
+    method: 'get',
+    params: {
+      uploadId
+    }
+  });
+}

+ 359 - 0
src/components/ChunkUpload.vue

@@ -0,0 +1,359 @@
+<template>
+  <div class="chunk-upload">
+    <el-upload
+      ref="uploadRef"
+      :auto-upload="false"
+      :on-change="handleFileChange"
+      :show-file-list="false"
+      drag
+    >
+      <el-icon class="el-icon--upload"><upload-filled /></el-icon>
+      <div class="el-upload__text">
+        将文件拖到此处,或<em>点击上传</em>
+      </div>
+      <template #tip>
+        <div class="el-upload__tip">
+          支持大文件分片上传,建议单个文件不超过5GB
+        </div>
+      </template>
+    </el-upload>
+
+    <!-- 上传进度 -->
+    <div v-if="uploading" class="upload-progress">
+      <el-progress 
+        :percentage="uploadProgress" 
+        :status="uploadStatus"
+        :stroke-width="8"
+      />
+      <div class="upload-info">
+        <span>文件名: {{ currentFile?.name }}</span>
+        <span>大小: {{ formatFileSize(currentFile?.size) }}</span>
+        <span>进度: {{ uploadedChunks }}/{{ totalChunks }} 分片</span>
+      </div>
+    </div>
+
+    <!-- 上传结果 -->
+    <div v-if="uploadResult" class="upload-result">
+      <el-alert
+        :title="uploadResult.success ? '上传成功' : '上传失败'"
+        :type="uploadResult.success ? 'success' : 'error'"
+        :description="uploadResult.message"
+        show-icon
+        :closable="false"
+      />
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed } from 'vue';
+import { ElMessage } from 'element-plus';
+import { UploadFilled } from '@element-plus/icons-vue';
+import { 
+  initMultipartUpload, 
+  uploadPart, 
+  completeMultipartUpload, 
+  abortMultipartUpload,
+  checkUploadedParts 
+} from '@/api/system/oss/multipart';
+
+// 分片大小:8MB
+const CHUNK_SIZE = 8 * 1024 * 1024;
+// 并发数:3
+const CONCURRENT_LIMIT = 3;
+
+// 响应式数据
+const uploadRef = ref();
+const uploading = ref(false);
+const uploadProgress = ref(0);
+const uploadStatus = ref<'success' | 'exception' | 'warning' | ''>('');
+const uploadedChunks = ref(0);
+const totalChunks = ref(0);
+const currentFile = ref<File | null>(null);
+const uploadResult = ref<{ success: boolean; message: string } | null>(null);
+
+// 上传状态
+const uploadId = ref('');
+const partETags = ref<string[]>([]);
+const uploadedParts = ref<Set<number>>(new Set());
+
+// 计算属性
+const isUploading = computed(() => uploading.value);
+
+// 处理文件选择
+const handleFileChange = (file: any) => {
+  if (file.raw) {
+    currentFile.value = file.raw;
+    startUpload(file.raw);
+  }
+};
+
+// 开始上传
+const startUpload = async (file: File) => {
+  try {
+    uploading.value = true;
+    uploadProgress.value = 0;
+    uploadStatus.value = '';
+    uploadResult.value = null;
+    uploadedChunks.value = 0;
+    partETags.value = [];
+    uploadedParts.value.clear();
+
+    // 计算分片数量
+    totalChunks.value = Math.ceil(file.size / CHUNK_SIZE);
+    
+    // 初始化分片上传
+    const initResponse = await initMultipartUpload(file.name, file.size);
+    if (!initResponse.success) {
+      throw new Error(initResponse.msg || '初始化分片上传失败');
+    }
+    uploadId.value = initResponse.data;
+
+    // 检查已上传的分片(断点续传)
+    await checkExistingParts();
+
+    // 开始上传分片
+    await uploadChunks(file);
+
+  } catch (error) {
+    console.error('上传失败:', error);
+    uploadStatus.value = 'exception';
+    uploadResult.value = {
+      success: false,
+      message: error instanceof Error ? error.message : '上传失败'
+    };
+  } finally {
+    uploading.value = false;
+  }
+};
+
+// 检查已上传的分片
+const checkExistingParts = async () => {
+  try {
+    const response = await checkUploadedParts(uploadId.value);
+    if (response.success && response.data) {
+      uploadedParts.value = new Set(response.data);
+      uploadedChunks.value = uploadedParts.value.size;
+      console.log('已上传分片:', Array.from(uploadedParts.value));
+    }
+  } catch (error) {
+    console.warn('检查已上传分片失败:', error);
+  }
+};
+
+// 上传分片
+const uploadChunks = async (file: File) => {
+  const chunks: { partNumber: number; data: Blob }[] = [];
+  
+  // 准备所有分片
+  for (let i = 0; i < totalChunks.value; i++) {
+    const start = i * CHUNK_SIZE;
+    const end = Math.min(start + CHUNK_SIZE, file.size);
+    const chunk = file.slice(start, end);
+    chunks.push({
+      partNumber: i + 1,
+      data: chunk
+    });
+  }
+
+  // 过滤掉已上传的分片
+  const remainingChunks = chunks.filter(chunk => !uploadedParts.value.has(chunk.partNumber));
+  
+  if (remainingChunks.length === 0) {
+    // 所有分片都已上传,直接完成
+    await completeUpload();
+    return;
+  }
+
+  // 并发上传分片
+  await uploadChunksConcurrently(remainingChunks);
+  
+  // 完成上传
+  await completeUpload();
+};
+
+// 并发上传分片
+const uploadChunksConcurrently = async (chunks: { partNumber: number; data: Blob }[]) => {
+  const semaphore = new Semaphore(CONCURRENT_LIMIT);
+  const uploadPromises: Promise<void>[] = [];
+
+  for (const chunk of chunks) {
+    const promise = semaphore.acquire().then(async (release) => {
+      try {
+        await uploadSingleChunk(chunk.partNumber, chunk.data);
+      } finally {
+        release();
+      }
+    });
+    uploadPromises.push(promise);
+  }
+
+  await Promise.all(uploadPromises);
+};
+
+// 上传单个分片
+const uploadSingleChunk = async (partNumber: number, chunkData: Blob) => {
+  const maxRetries = 3;
+  let retryCount = 0;
+
+  while (retryCount < maxRetries) {
+    try {
+      const formData = new FormData();
+      formData.append('file', chunkData);
+      
+      const response = await uploadPart(uploadId.value, partNumber, formData);
+      
+      if (response.success) {
+        partETags.value[partNumber - 1] = response.data;
+        uploadedChunks.value++;
+        uploadProgress.value = Math.round((uploadedChunks.value / totalChunks.value) * 100);
+        console.log(`分片 ${partNumber} 上传成功`);
+        return;
+      } else {
+        throw new Error(response.msg || '上传分片失败');
+      }
+    } catch (error) {
+      retryCount++;
+      console.error(`分片 ${partNumber} 上传失败 (重试 ${retryCount}/${maxRetries}):`, error);
+      
+      if (retryCount >= maxRetries) {
+        throw new Error(`分片 ${partNumber} 上传失败,已重试 ${maxRetries} 次`);
+      }
+      
+      // 等待一段时间后重试
+      await new Promise(resolve => setTimeout(resolve, 1000 * retryCount));
+    }
+  }
+};
+
+// 完成上传
+const completeUpload = async () => {
+  try {
+    const response = await completeMultipartUpload(
+      uploadId.value,
+      partETags.value.join(','),
+      currentFile.value?.name || '',
+      currentFile.value?.name || ''
+    );
+    
+    if (response.success) {
+      uploadStatus.value = 'success';
+      uploadResult.value = {
+        success: true,
+        message: '文件上传成功'
+      };
+      ElMessage.success('文件上传成功');
+    } else {
+      throw new Error(response.msg || '完成上传失败');
+    }
+  } catch (error) {
+    console.error('完成上传失败:', error);
+    uploadStatus.value = 'exception';
+    uploadResult.value = {
+      success: false,
+      message: error instanceof Error ? error.message : '完成上传失败'
+    };
+  }
+};
+
+// 取消上传
+const cancelUpload = async () => {
+  if (uploadId.value) {
+    try {
+      await abortMultipartUpload(uploadId.value);
+      console.log('已取消上传');
+    } catch (error) {
+      console.error('取消上传失败:', error);
+    }
+  }
+  
+  uploading.value = false;
+  uploadProgress.value = 0;
+  uploadStatus.value = '';
+  uploadResult.value = null;
+  uploadedChunks.value = 0;
+  totalChunks.value = 0;
+  currentFile.value = null;
+  uploadId.value = '';
+  partETags.value = [];
+  uploadedParts.value.clear();
+};
+
+// 格式化文件大小
+const formatFileSize = (bytes: number | undefined) => {
+  if (!bytes) return '0 B';
+  
+  const sizes = ['B', 'KB', 'MB', 'GB'];
+  const i = Math.floor(Math.log(bytes) / Math.log(1024));
+  return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
+};
+
+// 信号量类,用于控制并发
+class Semaphore {
+  private permits: number;
+  private waiting: (() => void)[] = [];
+
+  constructor(permits: number) {
+    this.permits = permits;
+  }
+
+  async acquire(): Promise<() => void> {
+    return new Promise((resolve) => {
+      if (this.permits > 0) {
+        this.permits--;
+        resolve(() => this.release());
+      } else {
+        this.waiting.push(() => {
+          this.permits--;
+          resolve(() => this.release());
+        });
+      }
+    });
+  }
+
+  private release() {
+    this.permits++;
+    if (this.waiting.length > 0) {
+      const next = this.waiting.shift();
+      if (next) next();
+    }
+  }
+}
+
+// 暴露方法给父组件
+defineExpose({
+  cancelUpload,
+  isUploading
+});
+</script>
+
+<style scoped>
+.chunk-upload {
+  width: 100%;
+}
+
+.upload-progress {
+  margin-top: 20px;
+  padding: 20px;
+  background-color: #f5f7fa;
+  border-radius: 4px;
+}
+
+.upload-info {
+  display: flex;
+  justify-content: space-between;
+  margin-top: 10px;
+  font-size: 14px;
+  color: #606266;
+}
+
+.upload-result {
+  margin-top: 20px;
+}
+
+.el-upload__tip {
+  color: #909399;
+  font-size: 12px;
+  margin-top: 7px;
+}
+</style>

+ 91 - 6
src/views/system/gameEvent/TaskList.vue

@@ -4,6 +4,10 @@
       <template #header>
         <div class="card-header">
           <span>参赛证生成任务</span>
+          <el-button type="primary" size="small" @click="handleRefresh">
+            <el-icon><Refresh /></el-icon>
+            刷新
+          </el-button>
         </div>
       </template>
       
@@ -32,7 +36,7 @@
             {{ scope.row.finishTime ? parseTime(scope.row.finishTime, '{y}-{m}-{d} {h}:{i}:{s}') : '-' }}
           </template>
         </el-table-column>
-        <el-table-column label="操作" width="200">
+        <el-table-column label="操作" width="280">
           <template #default="scope">
             <el-button 
               v-if="scope.row.status === '0'" 
@@ -51,6 +55,14 @@
             >
               {{ downloadingTasks.has(scope.row.taskId) ? '下载中...' : '下载' }}
             </el-button>
+            <el-button 
+              v-if="scope.row.status === '2'" 
+              type="primary" 
+              size="small" 
+              @click="handleCopyDownloadLink(scope.row.taskId)"
+            >
+              复制链接
+            </el-button>
             <el-button 
               type="danger" 
               size="small" 
@@ -77,7 +89,8 @@
 import { ref, onMounted, onUnmounted } from 'vue';
 import { useRouter } from 'vue-router';
 import { ElMessage, ElMessageBox } from 'element-plus';
-import { getTaskList, pauseTask, deleteTask, downloadTask } from '@/api/system/gameEvent/task';
+import { getTaskList, pauseTask, deleteTask, downloadTask, smartDownloadTask, getDownloadUrl } from '@/api/system/gameEvent/task';
+import { BASE_URL } from '@/config/api';
 
 const router = useRouter();
 
@@ -122,6 +135,78 @@ const handleCreateTask = () => {
   router.push('/system/gameEvent');
 };
 
+// 复制下载链接
+const handleCopyDownloadLink = async (taskId: number) => {
+  try {
+    // 获取当前任务信息,检查是否有OSS文件ID
+    const currentTask = taskList.value.find(task => task.taskId === taskId);
+    let downloadUrl;
+    
+    if (currentTask && currentTask.ossId) {
+      // 如果有OSS文件ID,获取预签名URL(最快下载方式)
+      try {
+        const response = await getDownloadUrl(taskId);
+        downloadUrl = response.msg || response.data;
+        if (!downloadUrl || downloadUrl === 'null' || downloadUrl === '') {
+          // 如果获取预签名URL失败,回退到代理下载
+          downloadUrl = `${BASE_URL}/system/number/public/downloadTask/${taskId}`;
+        }
+      } catch (error) {
+        console.warn('获取预签名URL失败,使用代理下载:', error);
+        downloadUrl = `${BASE_URL}/system/number/public/downloadTask/${taskId}`;
+      }
+    } else {
+      // 否则使用原有的下载链接
+      downloadUrl = `${BASE_URL}/system/number/public/downloadTask/${taskId}`;
+    }
+    
+    // 复制到剪贴板
+    await navigator.clipboard.writeText(downloadUrl);
+    ElMessage.success('下载链接已复制到剪贴板');
+  } catch (error) {
+    console.error('复制失败:', error);
+    // 降级方案:使用传统的复制方法
+    const currentTask = taskList.value.find(task => task.taskId === taskId);
+    let downloadUrl;
+    
+    if (currentTask && currentTask.ossId) {
+      // 如果有OSS文件ID,获取预签名URL(最快下载方式)
+      try {
+        const response = await getDownloadUrl(taskId);
+        downloadUrl = response.msg || response.data;
+        if (!downloadUrl || downloadUrl === 'null' || downloadUrl === '') {
+          // 如果获取预签名URL失败,回退到代理下载
+          downloadUrl = `${BASE_URL}/system/number/public/downloadTask/${taskId}`;
+        }
+      } catch (error) {
+        console.warn('获取预签名URL失败,使用代理下载:', error);
+        downloadUrl = `${BASE_URL}/system/number/public/downloadTask/${taskId}`;
+      }
+    } else {
+      // 否则使用原有的下载链接
+      downloadUrl = `${BASE_URL}/system/number/public/downloadTask/${taskId}`;
+    }
+    
+    const textArea = document.createElement('textarea');
+    textArea.value = downloadUrl;
+    document.body.appendChild(textArea);
+    textArea.select();
+    try {
+      document.execCommand('copy');
+      ElMessage.success('下载链接已复制到剪贴板');
+    } catch (fallbackError) {
+      ElMessage.error('复制失败,请手动复制链接');
+    }
+    document.body.removeChild(textArea);
+  }
+};
+
+// 手动刷新
+const handleRefresh = () => {
+  getList();
+  ElMessage.success('刷新成功');
+};
+
 // 停止任务
 const handleStopTask = async (taskId: number) => {
   try {
@@ -141,12 +226,12 @@ const handleStopTask = async (taskId: number) => {
   }
 };
 
-// 下载任务结果
-const handleDownload = (taskId: number) => {
+// 下载任务结果(使用智能下载)
+const handleDownload = async (taskId: number) => {
   downloadingTasks.value.add(taskId);
   try {
-    downloadTask(taskId);
-    ElMessage.success('下载完成');
+    await smartDownloadTask(taskId);
+    // ElMessage.success('下载完成');
   } catch (error) {
     ElMessage.error('下载失败');
   } finally {