From 153e76f32a386fc4ea42fcb65d987a430ab66574 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=BF=97=E5=BC=BA?= <357099073@qq.com> Date: Thu, 9 Apr 2026 18:07:55 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B9=E4=B8=83=E7=89=9B=E7=9B=B4=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/README.md | 1 + docs/七牛云直传配置.md | 443 ++++++++++++++++++ package-lock.json | 48 ++ package.json | 1 + src/utils/qiniuUpload.js | 225 +++++++++ .../softwareupgrade/components/edit.vue | 39 +- 6 files changed, 737 insertions(+), 20 deletions(-) create mode 100644 docs/七牛云直传配置.md create mode 100644 src/utils/qiniuUpload.js diff --git a/docs/README.md b/docs/README.md index dc472ff..1b00e8a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,6 +13,7 @@ - [一键复制](./一键复制.md) - [文件上传超时配置](./文件上传超时配置.md) - 大文件上传配置说明 - [图片URL处理说明](./图片URL处理说明.md) - 图片URL拼接处理 +- [七牛云直传配置](./七牛云直传配置.md) - ⚡ 七牛云直传上传(推荐!) ### 存储配置功能 存储配置功能的前端实现已完成,包括: diff --git a/docs/七牛云直传配置.md b/docs/七牛云直传配置.md new file mode 100644 index 0000000..015c74a --- /dev/null +++ b/docs/七牛云直传配置.md @@ -0,0 +1,443 @@ +# 七牛云直传配置说明 + +## 概述 + +新的上传机制实现了前端直接上传到七牛云,不再通过后端中转,大幅提升大文件上传效率。 + +## 上传流程对比 + +### 旧流程(低效) +``` +前端 → 后端服务器 → 七牛云 + (中转暂存) +``` + +问题: +- 大文件需要先上传到服务器,再由服务器上传到七牛云 +- 占用服务器带宽和磁盘空间 +- 上传时间翻倍 +- 服务器压力大 + +### 新流程(高效) +``` +前端 → 七牛云(直传) +后端 → 数据库(仅保存记录) +``` + +优势: +- 前端直接上传到七牛云,不经过服务器 +- 节省服务器资源 +- 上传速度快 +- 支持断点续传 + +## 安装依赖 + +### 前端安装七牛云 SDK + +```bash +cd platform +npm install qiniu-js +``` + +或使用 yarn: + +```bash +yarn add qiniu-js +``` + +## 后端 API + +### 1. 获取存储配置 + +**接口**: `GET /platform/storage/config` + +**响应**: +```json +{ + "code": 200, + "data": { + "storageType": "qiniu", // 或 "local" + "qiniuDomain": "http://7colud.yunzer.cn", + "qiniuRegion": "z0" + } +} +``` + +### 2. 获取上传凭证 + +**接口**: `GET /platform/qiniu/token` + +**响应**: +```json +{ + "code": 200, + "data": { + "token": "七牛云上传token", + "domain": "http://7colud.yunzer.cn", + "bucket": "your-bucket", + "region": "z0", + "keyPrefix": "2026/04/09/1775722615052606500", + "expires": 1712654400, + "uploadUrl": "https://up-z0.qiniup.com" + } +} +``` + +### 3. 保存文件记录 + +**接口**: `POST /platform/qiniu/save` + +**请求**: +```json +{ + "key": "2026/04/09/1775722615052606500.png", + "hash": "FhGxwBzoLwO_RGws...", + "size": 1024000, + "name": "screenshot.png", + "mimeType": "image/png", + "cate": 0 +} +``` + +**响应**: +```json +{ + "code": 200, + "data": { + "url": "http://7colud.yunzer.cn/2026/04/09/1775722615052606500.png", + "id": 123, + "name": "screenshot.png", + "key": "2026/04/09/1775722615052606500.png" + } +} +``` + +## 前端使用 + +### 基础用法 + +```javascript +import { smartUpload } from '@/utils/qiniuUpload'; + +// 自动选择上传方式(本地或七牛云) +const result = await smartUpload(file, { + cate: 0, // 文件分类 + onProgress: (progress) => { + console.log('上传进度:', progress.percent + '%'); + console.log('已上传:', progress.loaded); + console.log('总大小:', progress.total); + }, +}); + +console.log('上传成功:', result); +// { url: '...', id: 123, name: '...', key: '...' } +``` + +### 在组件中使用 + +```vue + + + +``` + +### 批量上传 + +```javascript +import { batchUpload } from '@/utils/qiniuUpload'; + +const files = [file1, file2, file3]; + +const results = await batchUpload(files, { + cate: 0, + onFileProgress: (file, progress) => { + console.log(`${file.name}: ${progress.percent}%`); + }, + onFileComplete: (file, result) => { + console.log(`${file.name} 上传成功:`, result); + }, + onFileError: (file, error) => { + console.error(`${file.name} 上传失败:`, error); + }, +}); + +console.log('所有文件上传完成:', results); +``` + +## 工作原理 + +### 1. 智能选择上传方式 + +`smartUpload` 函数会自动检测后端配置: + +```javascript +// 1. 获取存储配置 +const config = await getStorageConfig(); + +// 2. 根据配置选择上传方式 +if (config.storageType === 'qiniu') { + // 七牛云直传 + return uploadToQiniu(file, options); +} else { + // 本地上传(通过后端) + return uploadToLocal(file, options); +} +``` + +### 2. 七牛云直传流程 + +```javascript +// 1. 获取上传凭证 +const tokenRes = await getQiniuToken(); +const { token, keyPrefix } = tokenRes.data; + +// 2. 生成文件 key +const key = `${keyPrefix}.${ext}`; + +// 3. 使用七牛云 SDK 直接上传 +const observable = qiniu.upload(file, key, token); + +// 4. 监听上传进度 +observable.subscribe({ + next(res) { + // 进度回调 + onProgress(res.total.percent); + }, + complete(res) { + // 上传完成,保存记录到数据库 + await saveFileRecord({ + key: res.key, + hash: res.hash, + size: file.size, + name: file.name, + }); + }, +}); +``` + +### 3. 本地上传流程 + +```javascript +// 通过后端中转(兼容本地存储) +const formData = new FormData(); +formData.append('file', file); + +const res = await request({ + url: '/platform/uploadfile', + method: 'post', + data: formData, + onUploadProgress: (e) => { + onProgress(e.loaded / e.total * 100); + }, +}); +``` + +## 配置说明 + +### 七牛云区域配置 + +| 区域代码 | 区域名称 | 上传地址 | +|---------|---------|---------| +| z0 | 华东 | https://up-z0.qiniup.com | +| z1 | 华北 | https://up-z1.qiniup.com | +| z2 | 华南 | https://up-z2.qiniup.com | +| na0 | 北美 | https://up-na0.qiniup.com | +| as0 | 新加坡 | https://up-as0.qiniup.com | +| cn-east-2 | 华东-浙江2 | https://up-cn-east-2.qiniup.com | + +### 上传策略配置 + +后端生成 token 时的策略: + +```go +putPolicy := storage.PutPolicy{ + Scope: cfg.QiniuBucket, + ReturnBody: `{"key":"$(key)","hash":"$(etag)","size":$(fsize),"mimeType":"$(mimeType)"}`, + Expires: 3600, // 1小时有效期 +} +``` + +## 安全性 + +### 1. Token 有效期 + +上传 token 有效期为 1 小时,过期后需要重新获取。 + +### 2. 权限验证 + +- 获取 token 需要登录认证 +- 保存文件记录需要登录认证 +- 文件记录关联到当前用户和租户 + +### 3. 文件去重 + +通过 MD5 检查文件是否已存在,避免重复上传。 + +## 性能优化 + +### 1. 断点续传 + +七牛云 SDK 支持断点续传(大文件自动分片): + +```javascript +const config = { + useCdnDomain: true, + region: qiniu.region.z0, + chunkSize: 4, // 分片大小(MB) + concurrentRequestLimit: 3, // 并发上传数 +}; + +const observable = qiniu.upload(file, key, token, putExtra, config); +``` + +### 2. CDN 加速 + +启用 CDN 域名加速上传: + +```javascript +const config = { + useCdnDomain: true, // 使用 CDN 加速 +}; +``` + +### 3. 并发上传 + +批量上传时可以控制并发数: + +```javascript +// 限制同时上传 3 个文件 +const concurrency = 3; +const results = []; + +for (let i = 0; i < files.length; i += concurrency) { + const batch = files.slice(i, i + concurrency); + const batchResults = await Promise.all( + batch.map(file => smartUpload(file, options)) + ); + results.push(...batchResults); +} +``` + +## 故障排查 + +### 1. 上传失败:获取 token 失败 + +**错误**: "当前未配置七牛云存储" + +**解决**: +- 检查数据库存储配置 +- 确认 `storage_type` 为 `'qiniu'` +- 确认七牛云配置完整 + +### 2. 上传失败:token 无效 + +**错误**: "401 Unauthorized" + +**解决**: +- 检查 AccessKey 和 SecretKey 是否正确 +- 检查 token 是否过期(1小时有效期) +- 重新获取 token + +### 3. 上传失败:bucket 不存在 + +**错误**: "no such bucket" + +**解决**: +- 检查 bucket 名称是否正确 +- 检查 bucket 是否在对应区域 +- 登录七牛云控制台确认 + +### 4. 保存记录失败 + +**错误**: "保存文件记录失败" + +**解决**: +- 检查数据库连接 +- 检查文件信息是否完整 +- 查看后端日志 + +## 迁移指南 + +### 从旧版本迁移 + +1. **安装依赖**: +```bash +npm install qiniu-js +``` + +2. **更新导入**: +```javascript +// 旧版本 +import { uploadFile } from '@/api/file'; + +// 新版本 +import { smartUpload } from '@/utils/qiniuUpload'; +``` + +3. **更新上传代码**: +```javascript +// 旧版本 +const formData = new FormData(); +formData.append('file', file); +const res = await uploadFile(formData, { cate: 0 }); + +// 新版本 +const result = await smartUpload(file, { cate: 0 }); +``` + +4. **兼容性**: +- `smartUpload` 会自动检测存储配置 +- 如果配置为本地存储,会自动使用旧的上传方式 +- 无需修改其他代码 + +## 相关文件 + +### 后端 +- `go/controllers/qiniu_upload.go` - 七牛云上传控制器 +- `go/routers/platform/platform.go` - 路由配置 +- `go/services/storage_service.go` - 存储服务 + +### 前端 +- `platform/src/utils/qiniuUpload.js` - 七牛云上传工具 +- `platform/src/views/platform/softwareupgrade/components/edit.vue` - 软件升级组件(示例) + +## 更新日期 + +2026-04-09 diff --git a/package-lock.json b/package-lock.json index 491399f..dee9013 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "marked": "^16.4.1", "os": "^0.1.2", "pinia": "^3.0.3", + "qiniu-js": "^3.4.4", "vue": "^3.5.22", "vue-img-cutter": "^3.0.7", "vue-router": "^4.6.3", @@ -77,6 +78,26 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/runtime-corejs2": { + "version": "7.29.2", + "resolved": "https://registry.npmmirror.com/@babel/runtime-corejs2/-/runtime-corejs2-7.29.2.tgz", + "integrity": "sha512-+FqVkbqWaDleqS9fgzFypApKoPvmGFgk5X2lGXbL9wgz6tf88qt2HEUuEn9E3yBeLt7p8pIgODbJ5icVRALKhQ==", + "license": "MIT", + "dependencies": { + "core-js": "^2.6.12" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime-corejs2/node_modules/core-js": { + "version": "2.6.12", + "resolved": "https://registry.npmmirror.com/core-js/-/core-js-2.6.12.tgz", + "integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==", + "deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.", + "hasInstallScript": true, + "license": "MIT" + }, "node_modules/@babel/types": { "version": "7.29.0", "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", @@ -3685,6 +3706,17 @@ "license": "MIT", "optional": true }, + "node_modules/qiniu-js": { + "version": "3.4.4", + "resolved": "https://registry.npmmirror.com/qiniu-js/-/qiniu-js-3.4.4.tgz", + "integrity": "sha512-S/ooashZjyFQIIbxrte+OfwRxZQz5/MfVv55xWewc9GoxogP/xMmWixCaBEbdqmyjPAmJ2+VtZiWUuP8teB/BA==", + "license": "MIT", + "dependencies": { + "@babel/runtime-corejs2": "^7.10.2", + "querystring": "^0.2.1", + "spark-md5": "^3.0.0" + } + }, "node_modules/quansync": { "version": "0.2.11", "resolved": "https://registry.npmmirror.com/quansync/-/quansync-0.2.11.tgz", @@ -3702,6 +3734,16 @@ ], "license": "MIT" }, + "node_modules/querystring": { + "version": "0.2.1", + "resolved": "https://registry.npmmirror.com/querystring/-/querystring-0.2.1.tgz", + "integrity": "sha512-wkvS7mL/JMugcup3/rMitHmd9ecIGd2lhFhK9N3UUQ450h66d1r3Y9nvXzQAW1Lq+wyx61k/1pfKS5KuKiyEbg==", + "deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.", + "license": "MIT", + "engines": { + "node": ">=0.4.x" + } + }, "node_modules/readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz", @@ -4112,6 +4154,12 @@ "node": ">=0.10.0" } }, + "node_modules/spark-md5": { + "version": "3.0.2", + "resolved": "https://registry.npmmirror.com/spark-md5/-/spark-md5-3.0.2.tgz", + "integrity": "sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==", + "license": "(WTFPL OR MIT)" + }, "node_modules/speakingurl": { "version": "14.0.1", "resolved": "https://registry.npmmirror.com/speakingurl/-/speakingurl-14.0.1.tgz", diff --git a/package.json b/package.json index fd059da..11197d9 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "marked": "^16.4.1", "os": "^0.1.2", "pinia": "^3.0.3", + "qiniu-js": "^3.4.4", "vue": "^3.5.22", "vue-img-cutter": "^3.0.7", "vue-router": "^4.6.3", diff --git a/src/utils/qiniuUpload.js b/src/utils/qiniuUpload.js new file mode 100644 index 0000000..eb560c8 --- /dev/null +++ b/src/utils/qiniuUpload.js @@ -0,0 +1,225 @@ +import request from '@/utils/request'; +import * as qiniu from 'qiniu-js'; + +/** + * 获取存储配置 + * @returns {Promise<{storageType: string, qiniuDomain?: string, qiniuRegion?: string}>} + */ +export async function getStorageConfig() { + const res = await request({ + url: '/platform/storage/config', + method: 'get', + }); + if (res?.code === 200) { + return res.data || { storageType: 'local' }; + } + return { storageType: 'local' }; +} + +/** + * 获取七牛云上传凭证 + * @returns {Promise} + */ +export async function getQiniuToken() { + return request({ + url: '/platform/qiniu/token', + method: 'get', + }); +} + +/** + * 保存文件记录到数据库 + * @param {Object} data 文件信息 + * @returns {Promise} + */ +export async function saveFileRecord(data) { + return request({ + url: '/platform/qiniu/save', + method: 'post', + data, + }); +} + +/** + * 上传文件(自动选择本地或七牛云) + * @param {File} file 文件对象 + * @param {Object} options 配置选项 + * @param {number} [options.cate] 文件分类 + * @param {Function} [options.onProgress] 进度回调 + * @returns {Promise<{url: string, id: number, name: string, key?: string}>} + */ +export async function smartUpload(file, options = {}) { + // 获取存储配置 + const config = await getStorageConfig(); + + if (config.storageType === 'qiniu') { + // 使用七牛云直传 + return uploadToQiniu(file, options); + } else { + // 使用本地上传(通过后端) + return uploadToLocal(file, options); + } +} + +/** + * 上传到七牛云(直传) + * @param {File} file 文件对象 + * @param {Object} options 配置选项 + * @returns {Promise} + */ +export async function uploadToQiniu(file, options = {}) { + // 1. 获取上传凭证 + const tokenRes = await getQiniuToken(); + if (tokenRes?.code !== 200) { + throw new Error(tokenRes?.msg || '获取上传凭证失败'); + } + + const { token, keyPrefix, domain } = tokenRes.data; + + // 2. 生成文件 key + const ext = file.name.split('.').pop(); + const key = `${keyPrefix}.${ext}`; + + // 3. 配置上传参数 + const putExtra = { + fname: file.name, + mimeType: file.type || 'application/octet-stream', + }; + + const config = { + useCdnDomain: true, + region: qiniu.region.z0, // 根据实际区域配置 + }; + + // 4. 创建 observable 对象 + const observable = qiniu.upload(file, key, token, putExtra, config); + + // 5. 执行上传 + return new Promise((resolve, reject) => { + const subscription = observable.subscribe({ + next(res) { + // 进度回调 + if (options.onProgress) { + options.onProgress({ + loaded: res.total.loaded, + total: res.total.size, + percent: res.total.percent, + }); + } + }, + error(err) { + reject(new Error(err.message || '上传失败')); + }, + async complete(res) { + try { + // 6. 保存文件记录到数据库 + const saveRes = await saveFileRecord({ + key: res.key, + hash: res.hash, + size: file.size, + name: file.name, + mimeType: file.type, + cate: options.cate || 0, + }); + + if (saveRes?.code === 200 || saveRes?.code === 201) { + resolve({ + url: saveRes.data.url, + id: saveRes.data.id, + name: saveRes.data.name, + key: saveRes.data.key, + }); + } else { + reject(new Error(saveRes?.msg || '保存文件记录失败')); + } + } catch (error) { + reject(error); + } + }, + }); + }); +} + +/** + * 上传到本地(通过后端中转) + * @param {File} file 文件对象 + * @param {Object} options 配置选项 + * @returns {Promise} + */ +export async function uploadToLocal(file, options = {}) { + const formData = new FormData(); + formData.append('file', file); + + if (options.cate !== undefined) { + formData.append('cate', String(options.cate)); + } + + const config = { + url: '/platform/uploadfile', + method: 'post', + data: formData, + timeout: 0, // 不设置超时 + }; + + if (options.onProgress) { + config.onUploadProgress = (e) => { + options.onProgress({ + loaded: e.loaded, + total: e.total || 0, + percent: e.total > 0 ? Math.round((e.loaded * 100) / e.total) : 0, + }); + }; + } + + const res = await request(config); + + if (res?.code === 200 || res?.code === 201) { + return { + url: res.data.url, + id: res.data.id, + name: res.data.name, + }; + } else { + throw new Error(res?.msg || '上传失败'); + } +} + +/** + * 批量上传文件 + * @param {File[]} files 文件数组 + * @param {Object} options 配置选项 + * @param {Function} [options.onFileProgress] 单个文件进度回调 (file, progress) => void + * @param {Function} [options.onFileComplete] 单个文件完成回调 (file, result) => void + * @param {Function} [options.onFileError] 单个文件错误回调 (file, error) => void + * @returns {Promise} + */ +export async function batchUpload(files, options = {}) { + const results = []; + + for (const file of files) { + try { + const result = await smartUpload(file, { + ...options, + onProgress: (progress) => { + if (options.onFileProgress) { + options.onFileProgress(file, progress); + } + }, + }); + + results.push({ file, result, success: true }); + + if (options.onFileComplete) { + options.onFileComplete(file, result); + } + } catch (error) { + results.push({ file, error, success: false }); + + if (options.onFileError) { + options.onFileError(file, error); + } + } + } + + return results; +} diff --git a/src/views/platform/softwareupgrade/components/edit.vue b/src/views/platform/softwareupgrade/components/edit.vue index ab930ca..39cc2eb 100644 --- a/src/views/platform/softwareupgrade/components/edit.vue +++ b/src/views/platform/softwareupgrade/components/edit.vue @@ -115,7 +115,8 @@ import { createSoftwareUpgrade, updateSoftwareUpgrade, } from "@/api/softwareUpgrade"; -import { getUserCate, createFileCate, uploadFile, getFileById } from "@/api/file"; +import { getUserCate, createFileCate, getFileById } from "@/api/file"; +import { smartUpload } from "@/utils/qiniuUpload"; const emit = defineEmits(["saved"]); @@ -262,8 +263,7 @@ async function handlePackageUpload(options) { options.onError?.(new Error("no category")); return; } - const fd = new FormData(); - fd.append("file", file); + uploadXHRActive.value = true; uploadPercent.value = 0; uploadIndeterminate.value = false; @@ -273,20 +273,24 @@ async function handlePackageUpload(options) { let lastLoaded = 0; let lastTick = xhrStart; let emaBps = 0; - const res = await uploadFile(fd, { + + // 使用智能上传(自动选择本地或七牛云) + const result = await smartUpload(file, { cate: cateId, - onUploadProgress: (e) => { + onProgress: (progress) => { const now = Date.now(); - const loaded = e.loaded; - const total = e.total ?? 0; + const loaded = progress.loaded; + const total = progress.total || 0; const elapsedSec = (now - xhrStart) / 1000; const dt = (now - lastTick) / 1000; + if (dt >= 0.07 && loaded >= lastLoaded) { const inst = (loaded - lastLoaded) / dt; emaBps = emaBps > 0 ? emaBps * 0.72 + inst * 0.28 : inst; lastLoaded = loaded; lastTick = now; } + const avgBps = elapsedSec > 0.12 ? loaded / elapsedSec : 0; const showBps = emaBps > 0 ? emaBps : avgBps; uploadSpeedText.value = showBps > 0 ? formatSpeed(showBps) : elapsedSec > 0.05 ? formatSpeed(avgBps) : "—"; @@ -301,19 +305,14 @@ async function handlePackageUpload(options) { } }, }); - if (res?.code === 200 || res?.code === 201) { - const d = res.data || {}; - form.fileId = d.id != null ? Number(d.id) : null; - const src = d.url || ""; - form.downloadUrl = absoluteFromSrc(src); - uploadedLabel.value = d.name || file.name || "安装包"; - displayFileList.value = [{ name: uploadedLabel.value, uid: `pkg-${form.fileId}-${Date.now()}` }]; - ElMessage.success(res.code === 201 ? "文件已存在,已关联" : "上传成功"); - options.onSuccess?.(res); - } else { - ElMessage.error(res?.msg || "上传失败"); - options.onError?.(new Error(res?.msg)); - } + + // 上传成功 + form.fileId = result.id; + form.downloadUrl = result.url; + uploadedLabel.value = result.name || file.name || "安装包"; + displayFileList.value = [{ name: uploadedLabel.value, uid: `pkg-${form.fileId}-${Date.now()}` }]; + ElMessage.success("上传成功"); + options.onSuccess?.(result); } catch (e) { ElMessage.error(e?.message || "上传失败"); options.onError?.(e);