/** * 腾讯云COS管理器 - 完整版 * 支持MD5去重、文件记录保存、头像上传和动态图片上传两种场景 * 参考文档: https://cloud.tencent.com/document/product/436/31953 */ const COS = require('../dist/cos-wx-sdk-v5.min.js'); const config = require('../config/config.js'); const apiClient = require('./api-client.js'); const errorHandler = require('./error-handler.js'); const SparkMD5 = require('../dist/spark-md5.min.js'); class COSManager { constructor() { this.cosInstance = null; this.credentials = null; // 缓存的临时密钥(兼容旧代码) this.credentialsExpireTime = 0; // 密钥过期时间(兼容旧代码) this.credentialsCache = {}; // 按 fileType 缓存的临时密钥 { fileType: { credentials, expireTime } } this.currentFileType = null; // 当前正在上传的文件类型 this.isInitialized = false; } /** * 初始化COS实例 */ init() { if (this.isInitialized && this.cosInstance) { return; } try { this.cosInstance = new COS({ // 强烈推荐: 高级上传内部对小文件使用putObject SimpleUploadMethod: 'putObject', getAuthorization: (options, callback) => { // 使用当前正在上传的文件类型,而不是从 options.Scope 获取 // 因为 options.Scope?.[0]?.action 可能不是我们期望的 fileType const fileType = this.currentFileType || 'image'; // 从后端获取临时密钥 this.getCredentials(fileType) .then(credentials => { callback({ TmpSecretId: credentials.tmpSecretId, TmpSecretKey: credentials.tmpSecretKey, SecurityToken: credentials.sessionToken, StartTime: credentials.startTime, ExpiredTime: credentials.expiredTime }); }) .catch(error => { console.error('[COS] 获取临时密钥失败:', error); callback({ TmpSecretId: '', TmpSecretKey: '', SecurityToken: '', StartTime: 0, ExpiredTime: 0 }); }); } }); this.isInitialized = true; console.log('[COS] 初始化成功'); } catch (error) { console.error('[COS] 初始化失败:', error); errorHandler.handleError(error, { action: 'cos_init', showToast: true }); throw error; } } /** * 获取临时密钥 * @param {string} fileType - 文件类型 (image/video/audio/file/avatar) * @returns {Promise} 临时密钥信息 */ async getCredentials(fileType = 'image') { try { // 检查按 fileType 缓存的密钥是否还有效(提前5分钟过期) const now = Math.floor(Date.now() / 1000); const cached = this.credentialsCache[fileType]; if (cached && cached.credentials && cached.expireTime > now + 300) { // 验证缓存的密钥是否完整 const creds = cached.credentials; if (creds.tmpSecretId && creds.tmpSecretKey && creds.sessionToken) { console.log(`[COS] 使用缓存的 ${fileType} 临时密钥`); return creds; } else { // 缓存的密钥不完整,清除缓存 console.warn(`[COS] 缓存的 ${fileType} 临时密钥不完整,重新获取`); delete this.credentialsCache[fileType]; } } // 从后端获取新的临时密钥 console.log(`[COS] 获取新的 ${fileType} 临时密钥`); const response = await apiClient.get(config.cos.stsUrl, { fileType }); if (response.code === 200 && response.data) { const credentials = response.data; // 验证返回的密钥是否完整 if (!credentials.tmpSecretId || !credentials.tmpSecretKey || !credentials.sessionToken) { throw new Error('临时密钥数据不完整'); } // 按 fileType 缓存密钥 this.credentialsCache[fileType] = { credentials: credentials, expireTime: credentials.expiredTime }; // 兼容旧代码:也更新全局 credentials this.credentials = credentials; this.credentialsExpireTime = credentials.expiredTime; return credentials; } throw new Error(response.message || '获取临时密钥失败'); } catch (error) { console.error('[COS] 获取临时密钥失败:', error); // 清除可能损坏的缓存 if (this.credentialsCache[fileType]) { delete this.credentialsCache[fileType]; } throw error; } } /** * 计算文件MD5 * @param {string} filePath - 微信临时文件路径 * @returns {Promise} MD5值 */ async calculateMD5(filePath) { return new Promise((resolve, reject) => { try { const fileSystemManager = wx.getFileSystemManager(); // 读取文件内容 fileSystemManager.readFile({ filePath, success: (res) => { try { // 使用SparkMD5计算MD5 const spark = new SparkMD5.ArrayBuffer(); spark.append(res.data); const md5 = spark.end(); resolve(md5); } catch (error) { console.error('[COS] MD5计算失败:', error); reject(error); } }, fail: (error) => { console.error('[COS] 读取文件失败:', error); reject(error); } }); } catch (error) { console.error('[COS] 计算MD5异常:', error); reject(error); } }); } /** * 检查文件是否已存在(用于去重) * @param {string} md5Hash - 文件MD5值 * @returns {Promise} 检查结果 {exists, fileUrl, fileId, fileSize} */ async checkFileExists(md5Hash) { try { const response = await apiClient.post(config.cos.fileCheckUrl, { md5Hash }); if (response.code === 200 && response.data) { return response.data; } return { exists: false }; } catch (error) { console.error('[COS] 检查文件存在性失败:', error); // 检查失败不影响上传流程,返回不存在 return { exists: false }; } } /** * 保存文件记录到后端 * @param {Object} fileInfo - 文件信息 * @returns {Promise} 保存结果 */ async saveFileRecord(fileInfo) { try { const response = await apiClient.post(config.cos.fileRecordUrl, fileInfo); if (response.code === 200) { return response.data; } throw new Error(response.message || '保存文件记录失败'); } catch (error) { console.error('[COS] 保存文件记录失败:', error); throw error; } } /** * 生成文件路径 * @param {string} fileName - 原始文件名 * @param {string} fileType - 文件类型 * @param {number} userId - 用户ID * @returns {string} 完整文件路径 */ generateFilePath(fileName, fileType, userId) { const timestamp = Math.floor(Date.now() / 1000); // 路径格式: user/{userId}/{fileType}/{timestamp}_{fileName} return `user/${userId}/${fileType}/${timestamp}_${fileName}`; } /** * 生成文件URL * @param {string} objectPath - 对象路径 * @returns {string} 完整URL */ generateFileUrl(objectPath) { const { bucket, region } = config.cos; return `https://${bucket}.cos.${region}.myqcloud.com/${objectPath}`; } /** * 生成极致压缩的图片URL - 腾讯云COS图片处理 * 确保压缩到10KB以内 * @param {string} objectPath - 对象路径 * @param {Object} options - 压缩选项 * @returns {string} 压缩后的图片URL */ generateUltraCompressedImageUrl(objectPath, options = {}) { const { bucket, region } = config.cos; return `https://${bucket}.cos.${region}.myqcloud.com/${objectPath}`; } /** * 本地预压缩到10KB以内 - 使用微信canvas * @param {string} filePath - 原始图片路径 * @param {Object} options - 压缩选项 * @returns {Promise} 压缩后的临时文件路径 */ async compressToUnder10KB(filePath, options = {}) { return new Promise((resolve, reject) => { const { maxWidth = 400, maxHeight = 400, maxAttempts = 3, // 最大尝试次数 targetSize = 500 * 1024 // 目标大小:10KB } = options; console.log('[DEBUG] 解构后的targetSize:', targetSize); let attempt = 0; let currentQuality = 0.9; // 起始质量30% const tryCompress = () => { attempt++; if (attempt > maxAttempts) { reject(new Error(`无法压缩到10KB以内,最小体积: ${lastSize}字节`)); return; } wx.getImageInfo({ src: filePath, success: (imageInfo) => { const { width: originalWidth, height: originalHeight } = imageInfo; // 计算压缩尺寸 let targetWidth = originalWidth; let targetHeight = originalHeight; if (originalWidth > maxWidth || originalHeight > maxHeight) { const ratio = Math.min(maxWidth / originalWidth, maxHeight / originalHeight); targetWidth = Math.floor(originalWidth * ratio); targetHeight = Math.floor(originalHeight * ratio); } // 创建离屏canvas const canvas = wx.createOffscreenCanvas({ type: '2d', width: targetWidth, height: targetHeight }); const ctx = canvas.getContext('2d'); // 创建图片对象 const image = canvas.createImage(); image.onload = () => { // 绘制到canvas ctx.clearRect(0, 0, targetWidth, targetHeight); ctx.drawImage(image, 0, 0, targetWidth, targetHeight); // 转换为临时文件 wx.canvasToTempFilePath({ canvas: canvas, quality: currentQuality, fileType: 'jpg', // 使用jpg格式,压缩效果更好 success: async (res) => { try { // 获取文件大小 const fileInfo = await this.getFileInfo(res.tempFilePath); console.log(`[COS] 压缩尝试 ${attempt}: 质量${currentQuality}, 输入的targetSize:${targetSize},尺寸${targetWidth}x${targetHeight}, 大小${fileInfo.size}字节`); if (fileInfo.size <= targetSize) { // 达到目标大小 resolve(res.tempFilePath); } else { // 继续压缩:降低质量 currentQuality = Math.max(0.1, currentQuality * 0.9); // 质量降低40% tryCompress(); } } catch (error) { reject(error); } }, fail: (err) => { reject(new Error(`canvas转换失败: ${err.errMsg}`)); } }, this); }; image.onerror = () => { reject(new Error('图片加载失败')); }; image.src = filePath; }, fail: (err) => { reject(new Error(`获取图片信息失败: ${err.errMsg}`)); } }); }; tryCompress(); }); } /** * 核心上传方法 - 统一接口 * @param {string|string[]} filePath - 文件路径(单个或数组) * @param {Object} options - 上传配置 * @param {string} options.fileType - 文件类型: 'avatar'|'image'|'video'|'audio' (默认'image') * @param {boolean} options.enableDedup - 是否去重 (默认false) * @param {boolean} options.enableCompress - 是否启用极致压缩到10KB以内 (默认false) * @param {Object} options.compressOptions - 压缩配置 * @param {Function} options.onProgress - 进度回调 * @returns {Promise} 上传结果(单个对象或数组) */ async upload(filePath, { fileType = 'image', enableDedup = false, enableCompress = false, compressOptions = {}, onProgress } = {}) { // 批量上传 if (Array.isArray(filePath)) { return this._uploadMultiple(filePath, { fileType, enableDedup, enableCompress, compressOptions, onProgress }); } // 单文件上传 return this._uploadSingle(filePath, { fileType, enableDedup, enableCompress, compressOptions, onProgress }); } /** * 单文件上传(内部方法) */ async _uploadSingle(filePath, { fileType, enableDedup, enableCompress, compressOptions, onProgress }) { try { if (!this.isInitialized) this.init(); const userId = this._getUserId(); let finalFilePath = filePath; let isCompressed = false; console.log("cos-manager--->","enableCompress1: "+enableCompress+" compressOptions "+compressOptions+" fileUrl: "+fileUrl); // 极致压缩处理(仅对图片) if (enableCompress && this._isImageFile(filePath)) { try { finalFilePath = await this.compressToUnder10KB(filePath, compressOptions); isCompressed = true; console.log('[COS] 图片极致压缩完成'); } catch (compressError) { console.warn('[COS] 图片极致压缩失败,使用原文件:', compressError); // 压缩失败时使用原文件 finalFilePath = filePath; } } const fileName = finalFilePath.split('/').pop(); const fileInfo = await this.getFileInfo(finalFilePath); // 去重检查 if (enableDedup) { const dedupResult = await this._handleDedup(finalFilePath); if (dedupResult) { // 如果压缩过,清理临时文件 if (isCompressed && finalFilePath !== filePath) { this._cleanupTempFile(finalFilePath); } return { ...dedupResult, isCompressed: false // 去重文件不进行压缩 }; } } // 上传流程 const { objectPath, fileUrl, md5Hash } = await this._executeUpload( finalFilePath, fileName, fileType, userId, onProgress ); // 生成极致压缩URL(如果启用压缩) let compressedUrl = enableCompress && isCompressed ? this.generateUltraCompressedImageUrl(objectPath, compressOptions) : fileUrl; // 确定usageType(用于后端自动处理) const usageType = fileType === 'avatar' ? 'avatar' : 'feed'; console.log("cos-manager--->","enableCompress: "+enableCompress+" compressedUrl "+compressedUrl+" fileUrl: "+fileUrl); // 保存记录 const recordResult = await this._saveRecord({ fileName, fileInfo, fileType, objectPath, fileUrl: compressedUrl, originalUrl: fileUrl, // 保存原始URL md5Hash, usageType, relatedId: 0 }); // 清理临时压缩文件 if (isCompressed && finalFilePath !== filePath) { this._cleanupTempFile(finalFilePath); } const finalFileInfo = await this.getFileInfo(finalFilePath); return { success: true, fileUrl: recordResult.fileUrl, originalUrl: fileUrl, fileId: recordResult.fileId, fileSize: finalFileInfo.size, md5Hash, isDeduped: false, isCompressed: enableCompress && isCompressed, message: `文件上传成功${enableCompress && isCompressed ? '(已极致压缩到10KB以内)' : ''}` }; } catch (error) { console.error('[COS] 上传失败:', error); errorHandler.handleError(error, { action: 'cos_upload', showToast: true, toastMessage: '文件上传失败,请重试' }); throw error; } } /** * 批量上传(内部方法) */ async _uploadMultiple(filePaths, { fileType, enableDedup, enableCompress, compressOptions, onProgress }) { const results = []; const total = filePaths.length; for (let i = 0; i < total; i++) { try { const result = await this._uploadSingle(filePaths[i], { fileType, enableDedup, enableCompress, compressOptions, onProgress: (percent) => { if (onProgress) { onProgress({ current: i + 1, total, currentPercent: percent, overallPercent: Math.round(((i + percent / 100) / total) * 100) }); } } }); results.push(result); } catch (error) { console.error(`[COS] 文件 ${i + 1} 上传失败:`, error); results.push({ success: false, error: error.message || '上传失败' }); } } return results; } // ==================== 内部辅助方法 ==================== /** * 获取用户ID */ _getUserId() { const userInfo = wx.getStorageSync('userInfo'); const customId = userInfo.user?.customId; if (!userInfo || !customId) { throw new Error('用户未登录'); } return customId; } /** * 处理去重逻辑 */ async _handleDedup(filePath) { const md5Hash = await this.calculateMD5(filePath); const checkResult = await this.checkFileExists(md5Hash); if (checkResult.exists) { return { success: true, fileUrl: checkResult.fileUrl, originalUrl: checkResult.fileUrl, fileId: checkResult.fileId, fileSize: checkResult.fileSize, md5Hash, isDeduped: true, isCompressed: false, message: '文件已存在,直接使用' }; } return null; } /** * 执行上传到COS */ async _executeUpload(filePath, fileName, fileType, userId, onProgress) { // 设置当前正在上传的文件类型,供 getAuthorization 回调使用 this.currentFileType = fileType; try { // 先获取对应 fileType 的临时密钥(确保在上传前获取) const credentials = await this.getCredentials(fileType); // 验证临时密钥是否有效 if (!credentials || !credentials.tmpSecretId || !credentials.tmpSecretKey || !credentials.sessionToken) { throw new Error('临时密钥无效,请重新获取'); } const objectPath = this.generateFilePath(fileName, fileType, userId); const fileUrl = this.generateFileUrl(objectPath); await this.uploadToCOS({ filePath, key: objectPath, onProgress }); const md5Hash = await this.calculateMD5(filePath); return { objectPath, fileUrl, md5Hash }; } catch (error) { console.error('[COS] 上传执行失败:', error); // 清除缓存的临时密钥,强制下次重新获取 if (this.credentialsCache[fileType]) { delete this.credentialsCache[fileType]; } throw error; } finally { // 上传完成后清除当前文件类型 this.currentFileType = null; } } /** * 保存文件记录 */ async _saveRecord({ fileName, fileInfo, fileType, objectPath, fileUrl, md5Hash, usageType, relatedId }) { return await this.saveFileRecord({ fileName, fileSize: fileInfo.size, contentType: fileInfo.type || this.getContentType(fileName), fileType, objectPath, fileUrl, md5Hash, usageType, relatedId }); } /** * 检查是否为图片文件 */ _isImageFile(filePath) { const imageExts = ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp']; const ext = filePath.split('.').pop().toLowerCase(); return imageExts.includes(ext); } /** * 清理临时文件 */ _cleanupTempFile(filePath) { try { if (filePath && filePath.startsWith('http://tmp/')) { wx.getFileSystemManager().unlink({ filePath: filePath, fail: (err) => { console.warn('[COS] 临时文件清理失败:', err); } }); } } catch (error) { console.warn('[COS] 临时文件清理异常:', error); } } /** * 直接上传到COS(不经过去重和记录保存) */ uploadToCOS({ filePath, key, onProgress }) { return new Promise((resolve, reject) => { this.cosInstance.uploadFile({ Bucket: config.cos.bucket, Region: config.cos.region, Key: key, FilePath: filePath, SliceSize: 1024 * 1024 * 5, onProgress: (progressData) => { if (onProgress) { const percent = Math.round(progressData.percent * 100); onProgress(percent); } } }, (err, data) => { if (err) { console.error('[COS] 上传失败:', err.code, err.message); if (err.statusCode === 403 || err.code === 'AccessDenied') { console.error('[COS] 权限错误,请检查临时密钥配置'); } reject(err); } else { resolve(data); } }); }); } /** * 批量上传文件 * @deprecated 请使用 upload(filePaths, options) 代替 */ async uploadFiles(files, options = {}) { console.warn('[COS] uploadFiles已废弃,请使用upload()方法'); const filePaths = files.map(f => f.filePath || f); return this.upload(filePaths, options); } /** * 完整上传方法(保留向后兼容) * @deprecated 请使用 upload(filePath, options) 代替 */ async uploadFile(options) { console.warn('[COS] uploadFile已废弃,请使用upload()方法'); const { filePath, fileType = 'image', enableDedup = false, onProgress } = options; return this.upload(filePath, { fileType, enableDedup, onProgress }); } /** * 获取文件信息 * @param {string} filePath - 文件路径 * @returns {Promise} 文件信息 */ getFileInfo(filePath) { return new Promise((resolve, reject) => { wx.getFileInfo({ filePath, success: (res) => { const ext = filePath.split('.').pop().toLowerCase(); const type = this.getContentType('file.' + ext); resolve({ size: res.size, type }); }, fail: reject }); }); } /** * 根据文件名获取Content-Type */ getContentType(fileName) { const ext = fileName.split('.').pop().toLowerCase(); const mimeTypes = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp', mp4: 'video/mp4', avi: 'video/x-msvideo', mov: 'video/quicktime', mp3: 'audio/mpeg', wav: 'audio/wav', pdf: 'application/pdf', txt: 'text/plain', json: 'application/json' }; return mimeTypes[ext] || 'application/octet-stream'; } /** * 删除文件 */ deleteObject(key) { return new Promise((resolve, reject) => { if (!this.isInitialized) { this.init(); } this.cosInstance.deleteObject({ Bucket: config.cos.bucket, Region: config.cos.region, Key: key }, (err, data) => { if (err) { console.error('[COS] 删除文件失败:', err); reject(err); } else { resolve(data); } }); }); } /** * 获取文件访问URL */ getObjectUrl(key, options = {}) { if (!this.isInitialized) { this.init(); } return this.cosInstance.getObjectUrl({ Bucket: config.cos.bucket, Region: config.cos.region, Key: key, Sign: options.sign !== false, Expires: options.expires || 3600 }); } /** * 专门针对10KB以内压缩的上传方法 * @param {string} filePath - 文件路径 * @param {Object} options - 选项 * @returns {Promise} 上传结果 */ async uploadWithUltraCompression(filePath, options = {}) { return this.upload(filePath, { fileType: 'image', enableCompress: true, enableDedup: options.enableDedup || false, compressOptions: { maxWidth: 400, maxHeight: 400, targetSize: 20 * 1024, // 10KB ...options } }); } } // 导出单例 const cosManager = new COSManager(); module.exports = cosManager;