Qwen2API-A / src /utils /upload.js
github-actions[bot]
Sync from GitHub Viciy2023/Qwen2API-A@ae093476e9bc5b0a599620b5925df3a20057038e
f120063
const axios = require('axios')
const OSS = require('ali-oss')
const mimetypes = require('mime-types')
const { logger } = require('./logger')
const { generateUUID } = require('./tools.js')
const { getProxyAgent, getChatBaseUrl, applyProxyToAxiosConfig } = require('./proxy-helper')
// 配置常量
const UPLOAD_CONFIG = {
get stsTokenUrl() {
return `${getChatBaseUrl()}/api/v1/files/getstsToken`
},
maxRetries: 3,
timeout: 30000,
maxFileSize: 100 * 1024 * 1024, // 100MB
retryDelay: 1000
}
// 支持的文件类型
const SUPPORTED_TYPES = {
image: ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp'],
video: ['video/mp4', 'video/avi', 'video/mov', 'video/wmv', 'video/flv'],
audio: ['audio/mp3', 'audio/wav', 'audio/aac', 'audio/ogg'],
document: ['application/pdf', 'text/plain', 'application/msword']
}
/**
* 验证文件大小
* @param {number} fileSize - 文件大小(字节)
* @returns {boolean} 是否符合大小限制
*/
const validateFileSize = (fileSize) => {
return fileSize > 0 && fileSize <= UPLOAD_CONFIG.maxFileSize
}
/**
* 从完整MIME类型获取简化的文件类型
* @param {string} mimeType - 完整的MIME类型
* @returns {string} 简化文件类型
*/
const getSimpleFileType = (mimeType) => {
if (!mimeType) return 'file'
const mainType = mimeType.split('/')[0].toLowerCase()
// 检查是否为支持的主要类型
if (Object.keys(SUPPORTED_TYPES).includes(mainType)) {
return mainType
}
return 'file'
}
/**
* 延迟函数
* @param {number} ms - 延迟毫秒数
*/
const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms))
/**
* 请求STS Token(带重试机制)
* @param {string} filename - 文件名
* @param {number} filesize - 文件大小(字节)
* @param {string} filetypeSimple - 简化文件类型
* @param {string} authToken - 认证Token
* @param {number} retryCount - 重试次数
* @returns {Promise<Object>} STS Token响应数据
*/
const requestStsToken = async (filename, filesize, filetypeSimple, authToken, retryCount = 0) => {
try {
// 参数验证
if (!filename || !authToken) {
logger.error('文件名和认证Token不能为空', 'UPLOAD')
throw new Error('文件名和认证Token不能为空')
}
if (!validateFileSize(filesize)) {
logger.error(`文件大小超出限制,最大允许 ${UPLOAD_CONFIG.maxFileSize / 1024 / 1024}MB`, 'UPLOAD')
throw new Error(`文件大小超出限制,最大允许 ${UPLOAD_CONFIG.maxFileSize / 1024 / 1024}MB`)
}
const requestId = generateUUID()
const bearerToken = authToken.startsWith('Bearer ') ? authToken : `Bearer ${authToken}`
const proxyAgent = getProxyAgent()
const headers = {
'Authorization': bearerToken,
'Content-Type': 'application/json',
'x-request-id': requestId,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
const payload = {
filename,
filesize,
filetype: filetypeSimple
}
const requestConfig = {
headers,
timeout: UPLOAD_CONFIG.timeout
}
// 添加代理配置
if (proxyAgent) {
requestConfig.httpsAgent = proxyAgent
requestConfig.proxy = false
}
logger.info(`请求STS Token: ${filename} (${filesize} bytes, ${filetypeSimple})`, 'UPLOAD', '🎫')
const response = await axios.post(UPLOAD_CONFIG.stsTokenUrl, payload, requestConfig)
if (response.status === 200 && response.data) {
const stsData = response.data
// 验证响应数据完整性
const credentials = {
access_key_id: stsData.access_key_id,
access_key_secret: stsData.access_key_secret,
security_token: stsData.security_token
}
const fileInfo = {
url: stsData.file_url,
path: stsData.file_path,
bucket: stsData.bucketname,
endpoint: stsData.region + '.aliyuncs.com',
id: stsData.file_id
}
// 检查必要字段
const requiredCredentials = ['access_key_id', 'access_key_secret', 'security_token']
const requiredFileInfo = ['url', 'path', 'bucket', 'endpoint', 'id']
const missingCredentials = requiredCredentials.filter(key => !credentials[key])
const missingFileInfo = requiredFileInfo.filter(key => !fileInfo[key])
if (missingCredentials.length > 0 || missingFileInfo.length > 0) {
logger.error(`STS响应数据不完整: 缺少 ${[...missingCredentials, ...missingFileInfo].join(', ')}`, 'UPLOAD')
throw new Error(`STS响应数据不完整: 缺少 ${[...missingCredentials, ...missingFileInfo].join(', ')}`)
}
logger.success('STS Token获取成功', 'UPLOAD')
return { credentials, file_info: fileInfo }
} else {
logger.error(`获取STS Token失败,状态码: ${response.status}`, 'UPLOAD')
throw new Error(`获取STS Token失败,状态码: ${response.status}`)
}
} catch (error) {
logger.error(`请求STS Token失败 (重试: ${retryCount})`, 'UPLOAD', '', error)
// 403错误特殊处理
if (error.response?.status === 403) {
logger.error('403 Forbidden错误,可能是Token权限问题', 'UPLOAD')
logger.error('认证失败,请检查Token权限', 'UPLOAD')
throw new Error('认证失败,请检查Token权限')
}
// 重试逻辑
if (retryCount < UPLOAD_CONFIG.maxRetries &&
(error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT' ||
error.response?.status >= 500)) {
const delayMs = UPLOAD_CONFIG.retryDelay * Math.pow(2, retryCount)
logger.warn(`等待 ${delayMs}ms 后重试...`, 'UPLOAD', '⏳')
await delay(delayMs)
return requestStsToken(filename, filesize, filetypeSimple, authToken, retryCount + 1)
}
throw error
}
}
/**
* 使用STS凭证将文件Buffer上传到阿里云OSS(带重试机制)
* @param {Buffer} fileBuffer - 文件内容的Buffer
* @param {Object} stsCredentials - STS凭证
* @param {Object} ossInfo - OSS信息
* @param {string} fileContentTypeFull - 文件的完整MIME类型
* @param {number} retryCount - 重试次数
* @returns {Promise<Object>} 上传结果
*/
const uploadToOssWithSts = async (fileBuffer, stsCredentials, ossInfo, fileContentTypeFull, retryCount = 0) => {
try {
// 参数验证
if (!fileBuffer || !stsCredentials || !ossInfo) {
logger.error('缺少必要的上传参数', 'UPLOAD')
throw new Error('缺少必要的上传参数')
}
const client = new OSS({
accessKeyId: stsCredentials.access_key_id,
accessKeySecret: stsCredentials.access_key_secret,
stsToken: stsCredentials.security_token,
bucket: ossInfo.bucket,
endpoint: ossInfo.endpoint,
secure: true,
timeout: UPLOAD_CONFIG.timeout
})
logger.info(`上传文件到OSS: ${ossInfo.path} (${fileBuffer.length} bytes)`, 'UPLOAD', '📤')
const result = await client.put(ossInfo.path, fileBuffer, {
headers: {
'Content-Type': fileContentTypeFull || 'application/octet-stream'
}
})
if (result.res && result.res.status === 200) {
logger.success('文件上传到OSS成功', 'UPLOAD')
return { success: true, result }
} else {
logger.error(`OSS上传失败,状态码: ${result.res?.status || 'unknown'}`, 'UPLOAD')
throw new Error(`OSS上传失败,状态码: ${result.res?.status || 'unknown'}`)
}
} catch (error) {
logger.error(`OSS上传失败 (重试: ${retryCount})`, 'UPLOAD', '', error)
// 重试逻辑
if (retryCount < UPLOAD_CONFIG.maxRetries) {
const delayMs = UPLOAD_CONFIG.retryDelay * Math.pow(2, retryCount)
logger.warn(`等待 ${delayMs}ms 后重试OSS上传...`, 'UPLOAD', '⏳')
await delay(delayMs)
return uploadToOssWithSts(fileBuffer, stsCredentials, ossInfo, fileContentTypeFull, retryCount + 1)
}
throw error
}
}
/**
* 完整的文件上传流程:获取STS Token -> 上传到OSS。
* @param {Buffer} fileBuffer - 图片文件的Buffer。
* @param {string} originalFilename - 原始文件名 (例如 "image.png")。
* @param {string} qwenAuthToken - 通义千问认证Token (纯token,不含Bearer)。
* @returns {Promise<{file_url: string, file_id: string, message: string}>} 包含上传后的URL、文件ID和成功消息。
* @throws {Error} 如果任何步骤失败。
*/
const uploadFileToQwenOss = async (fileBuffer, originalFilename, authToken) => {
try {
// 参数验证
if (!fileBuffer || !originalFilename || !authToken) {
logger.error('缺少必要的上传参数', 'UPLOAD')
throw new Error('缺少必要的上传参数')
}
const filesize = fileBuffer.length
const mimeType = mimetypes.lookup(originalFilename) || 'application/octet-stream'
const filetypeSimple = getSimpleFileType(mimeType)
// 文件大小验证
if (!validateFileSize(filesize)) {
logger.error(`文件大小超出限制,最大允许 ${UPLOAD_CONFIG.maxFileSize / 1024 / 1024}MB`, 'UPLOAD')
throw new Error(`文件大小超出限制,最大允许 ${UPLOAD_CONFIG.maxFileSize / 1024 / 1024}MB`)
}
logger.info(`开始上传文件: ${originalFilename} (${filesize} bytes, ${mimeType})`, 'UPLOAD', '📤')
// 第一步:获取STS Token
const { credentials, file_info } = await requestStsToken(
originalFilename,
filesize,
filetypeSimple,
authToken
)
// 第二步:上传到OSS
await uploadToOssWithSts(fileBuffer, credentials, file_info, mimeType)
logger.success('文件上传流程完成', 'UPLOAD')
return {
status: 200,
file_url: file_info.url,
file_id: file_info.id,
message: '文件上传成功'
}
} catch (error) {
logger.error('文件上传流程失败', 'UPLOAD', '', error)
throw error
}
}
module.exports = {
uploadFileToQwenOss
}