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

600 lines
17 KiB
JavaScript
Raw 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.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;