/** * 腾讯云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.isInitialized = false; } /** * 初始化COS实例 */ init() { if (this.isInitialized && this.cosInstance) { return; } try { this.cosInstance = new COS({ // 强烈推荐: 高级上传内部对小文件使用putObject SimpleUploadMethod: 'putObject', getAuthorization: (options, callback) => { // 从后端获取临时密钥 this.getCredentials(options.Scope?.[0]?.action) .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 { // 检查缓存的密钥是否还有效(提前5分钟过期) const now = Math.floor(Date.now() / 1000); if (this.credentials && this.credentialsExpireTime > now + 300) { return this.credentials; } // 从后端获取新的临时密钥 const response = await apiClient.get(config.cos.stsUrl, { fileType }); if (response.code === 200 && response.data) { this.credentials = response.data; this.credentialsExpireTime = response.data.expiredTime; return this.credentials; } throw new Error(response.message || '获取临时密钥失败'); } catch (error) { console.error('[COS] 获取临时密钥失败:', error); 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 - 对象路径 * @param {Object|string} thumbParams - 缩略图参数,支持数组或字符串 * @returns {string} 完整URL */ generateFileUrl(objectPath, thumbParams) { const { bucket, region } = config.cos; let baseUrl = `https://${bucket}.cos.${region}.myqcloud.com/${objectPath}`; // 处理缩略图参数 if (thumbParams) { let paramStr = ''; // 处理旧方法:字符串参数 if (typeof thumbParams === 'string' && thumbParams) { paramStr = thumbParams; } // 处理新方法:数组参数 else if (Array.isArray(thumbParams) && thumbParams.length) { paramStr = thumbParams.join(','); } // 处理对象参数 else if (typeof thumbParams === 'object' && thumbParams !== null) { const allowedParams = ['w', 'h', 'l', 's', 'q', 'r', 'g', 'bg', 'x', 'y', 'a', 'b', 'p', 'th']; const paramItems = []; for (const key in thumbParams) { if (allowedParams.includes(key) && thumbParams[key] !== undefined) { paramItems.push(`${key}_${thumbParams[key]}`); } } paramStr = paramItems.join(','); } if (paramStr) { const separator = baseUrl.includes('?') ? '&' : '?'; baseUrl += `${separator}imageMogr2/auto-orient/thumbnail/${paramStr}`; } } return baseUrl; } /** * 核心上传方法 - 统一接口 * @param {string|string[]} filePath - 文件路径(单个或数组) * @param {Object} options - 上传配置 * @param {string} options.fileType - 文件类型: 'avatar'|'image'|'video'|'audio' (默认'image') * @param {boolean} options.enableDedup - 是否去重 (默认false) * @param {Function} options.onProgress - 进度回调 * @param {Object|string|Array} options.thumbParams - 缩略图参数,参考腾讯COS API * @returns {Promise} 上传结果(单个对象或数组) */ async upload(filePath, { fileType = 'image', enableDedup = false, onProgress, thumbParams } = {}) { // 批量上传 if (Array.isArray(filePath)) { return this._uploadMultiple(filePath, { fileType, enableDedup, onProgress, thumbParams }); } // 单文件上传 return this._uploadSingle(filePath, { fileType, enableDedup, onProgress, thumbParams }); } /** * 单文件上传(内部方法) */ async _uploadSingle(filePath, { fileType, enableDedup, onProgress, thumbParams }) { try { if (!this.isInitialized) this.init(); const userId = this._getUserId(); const fileName = filePath.split('/').pop(); const fileInfo = await this.getFileInfo(filePath); // 去重检查 if (enableDedup) { const dedupResult = await this._handleDedup(filePath); if (dedupResult) { // 为去重文件添加缩略图参数 if (thumbParams && dedupResult.fileUrl) { const baseUrl = dedupResult.fileUrl.split('?')[0]; dedupResult.fileUrl = this.generateFileUrl(baseUrl.replace(/^https?:\/\/.*?\//, ''), thumbParams); } return dedupResult; } } // 上传流程 const { objectPath, fileUrl, md5Hash } = await this._executeUpload( filePath, fileName, fileType, userId, onProgress, thumbParams ); // 确定usageType(用于后端自动处理) const usageType = fileType === 'avatar' ? 'avatar' : 'feed'; // 保存记录 - 同时保存原始URL和缩略图URL const recordData = { fileName, fileInfo, fileType, objectPath, fileUrl: fileUrl.split('?')[0], md5Hash, usageType, relatedId: 0 }; if (thumbParams) { recordData.thumbUrl = fileUrl; } const recordResult = await this._saveRecord(recordData); return { success: true, fileUrl, originalUrl: fileUrl.split('?')[0], fileId: recordResult.fileId, fileSize: fileInfo.size, md5Hash, isDeduped: false, message: '文件上传成功' }; } catch (error) { console.error('[COS] 上传失败:', error); errorHandler.handleError(error, { action: 'cos_upload', showToast: true, toastMessage: '文件上传失败,请重试' }); throw error; } } /** * 批量上传(内部方法) */ async _uploadMultiple(filePaths, { fileType, enableDedup, onProgress, thumbParams }) { const results = []; const total = filePaths.length; for (let i = 0; i < total; i++) { try { const result = await this._uploadSingle(filePaths[i], { fileType, enableDedup, thumbParams, 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, fileId: checkResult.fileId, fileSize: checkResult.fileSize, md5Hash, isDeduped: true, message: '文件已存在,直接使用' }; } return null; } /** * 执行上传到COS */ async _executeUpload(filePath, fileName, fileType, userId, onProgress, thumbParams) { await this.getCredentials(fileType); const objectPath = this.generateFilePath(fileName, fileType, userId); const fileUrl = this.generateFileUrl(objectPath, thumbParams); await this.uploadToCOS({ filePath, key: objectPath, onProgress }); const md5Hash = await this.calculateMD5(filePath); return { objectPath, fileUrl, md5Hash }; } /** * 保存文件记录 */ async _saveRecord({ fileName, fileInfo, fileType, objectPath, fileUrl, md5Hash, usageType, relatedId, thumbUrl }) { return await this.saveFileRecord({ fileName, fileSize: fileInfo.size, contentType: fileInfo.type || this.getContentType(fileName), fileType, objectPath, fileUrl, thumbUrl, // 新增缩略图URL记录 md5Hash, usageType, relatedId }); } /** * 直接上传到COS(不经过去重和记录保存) * 使用 uploadFile 高级接口(官方强烈推荐) * 自动判断小文件用putObject,大文件用分块上传 * @param {Object} options - 上传选项 * @returns {Promise} */ uploadToCOS({ filePath, key, onProgress }) { return new Promise((resolve, reject) => { // 使用 uploadFile 高级接口(推荐) // 小文件自动使用putObject,大文件自动使用分块上传 this.cosInstance.uploadFile({ Bucket: config.cos.bucket, Region: config.cos.region, Key: key, FilePath: filePath, SliceSize: 1024 * 1024 * 5, // 大于5MB使用分块上传 onProgress: (progressData) => { if (onProgress) { const percent = Math.round(progressData.percent * 100); onProgress(percent); } } }, (err, data) => { if (err) { console.error('[COS] 上传失败:', err.code, err.message); // 403错误特别提示 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, thumbParams } = options; return this.upload(filePath, { fileType, enableDedup, onProgress, thumbParams }); } /** * 获取文件信息 * @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 * @param {string} fileName - 文件名 * @returns {string} 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', wmv: 'video/x-ms-wmv', '3gp': 'video/3gpp', // 音频 mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg', aac: 'audio/aac', m4a: 'audio/mp4', // 其他 pdf: 'application/pdf', doc: 'application/msword', docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', xls: 'application/vnd.ms-excel', xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', txt: 'text/plain', json: 'application/json' }; return mimeTypes[ext] || 'application/octet-stream'; } /** * 删除文件 * @param {string} key - 文件key * @returns {Promise} */ 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 * @param {string} key - 文件key * @param {Object} options - 选项 * @param {Object|string|Array} options.thumbParams - 缩略图参数 * @returns {string} 访问URL */ getObjectUrl(key, options = {}) { if (!this.isInitialized) { this.init(); } const baseUrl = this.cosInstance.getObjectUrl({ Bucket: config.cos.bucket, Region: config.cos.region, Key: key, Sign: options.sign !== false, // 默认需要签名 Expires: options.expires || 3600 // 默认1小时 }); // 处理缩略图参数 if (options.thumbParams) { return this.generateFileUrl(key, options.thumbParams); } return baseUrl; } } // 导出单例 const cosManager = new COSManager(); module.exports = cosManager;