findme-miniprogram-frontend/utils/cos-manager.js
2025-12-27 17:16:03 +08:00

809 lines
No EOL
24 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 腾讯云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;