600 lines
17 KiB
JavaScript
600 lines
17 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.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<Object>} 临时密钥信息
|
||
*/
|
||
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<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 - 对象路径
|
||
* @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<Object|Array>} 上传结果(单个对象或数组)
|
||
*/
|
||
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<void>}
|
||
*/
|
||
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<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
|
||
* @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<void>}
|
||
*/
|
||
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;
|