809 lines
24 KiB
JavaScript
809 lines
24 KiB
JavaScript
|
|
/**
|
|||
|
|
* 腾讯云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<Object>} 临时密钥信息
|
|||
|
|
*/
|
|||
|
|
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<string>} 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<Object>} 检查结果 {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<Object>} 保存结果
|
|||
|
|
*/
|
|||
|
|
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<string>} 压缩后的临时文件路径
|
|||
|
|
*/
|
|||
|
|
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<Object|Array>} 上传结果(单个对象或数组)
|
|||
|
|
*/
|
|||
|
|
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<Object>} 文件信息
|
|||
|
|
*/
|
|||
|
|
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<Object>} 上传结果
|
|||
|
|
*/
|
|||
|
|
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;
|