daShangDao_scanBook/utils/minio.js

465 lines
13 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.

/**
* MinIO 文件上传工具
* 使用 AWS Signature V4 签名,通过 PUT 直传到 MinIO
*/
import sha256 from 'js-sha256'
// ====== Polyfill: atob (某些 App 环境不支持) ======
if (typeof atob === 'undefined') {
var atob = function (input) {
var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='
var output = ''
input = input.replace(/=+$/, '')
for (var bc = 0, bs = 0, buffer, i = 0; buffer = input.charAt(i++); ~buffer && (bs = bc % 4 ? bs * 64 + buffer : buffer, bc++ % 4) ? output += String.fromCharCode(255 & bs >> (-2 * bc & 6)) : 0) {
buffer = chars.indexOf(buffer)
}
return output
}
}
// ====== MinIO 配置 ======
const CFG = {
endpoint: 'shxy.image.yushutx.com',
accessKey: 'minioadmin',
secretKey: 'minioadmin',
bucket: 'scan-book',
region: 'us-east-1',
protocol: 'https'
}
// ====== 工具函数 ======
function pad(n) {
return n < 10 ? '0' + n : '' + n
}
function hexToBytes(hex) {
const bytes = []
for (let i = 0; i < hex.length; i += 2) {
bytes.push(parseInt(hex.substring(i, i + 2), 16))
}
return new Uint8Array(bytes)
}
function bytesToHex(bytes) {
return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('')
}
/**
* HMAC-SHA256 返回 Uint8Array用于签名链
*/
function hmacRaw(key, msg) {
const hexResult = sha256.hmac(key, msg)
return hexToBytes(hexResult)
}
/**
* 生成 AWS V4 签名密钥链
*/
function getSignatureKey(secretKey, dateStamp, region, service) {
let k = hmacRaw('AWS4' + secretKey, dateStamp)
k = hmacRaw(k, region)
k = hmacRaw(k, service)
return hmacRaw(k, 'aws4_request')
}
function sha256Hex(str) {
return sha256(str)
}
// ====== 时间同步 ======
/** 缓存服务器时间偏移量(毫秒) */
var _timeOffset = 0
var _timeSyncing = false
/**
* 从 MinIO 服务器同步时间
* 通过 GET 根路径获取 Date 响应头,计算客户端与服务端的时间差
*/
function syncServerTime() {
return new Promise(function (resolve) {
if (_timeSyncing) {
setTimeout(function () { resolve(getServerDate()) }, 500)
return
}
_timeSyncing = true
var url = CFG.protocol + '://' + CFG.endpoint + '/'
console.log('【MinIO】同步服务器时间:', url)
uni.request({
url: url,
method: 'GET',
success: function (res) {
var serverDateStr = null
// 尝试从多个地方取 Date 头
if (res.header) {
serverDateStr = res.header.Date || res.header.date || res.header['Date']
}
if (res.headers) {
serverDateStr = serverDateStr || res.headers.Date || res.headers.date || res.headers['Date']
}
if (serverDateStr) {
var serverMs = new Date(serverDateStr).getTime()
if (serverMs) {
_timeOffset = serverMs - Date.now()
console.log('【MinIO】时间同步完成服务器偏移:', _timeOffset, 'ms, 服务器时间:', serverDateStr)
}
} else {
console.warn('【MinIO】未获取到Date响应头, header:', JSON.stringify(res.header || res.headers))
}
_timeSyncing = false
resolve(getServerDate())
},
fail: function (err) {
console.warn('【MinIO】时间同步请求失败:', JSON.stringify(err))
_timeSyncing = false
resolve(new Date())
}
})
})
}
/**
* 获取对齐服务器时间后的当前时间
*/
function getServerDate() {
return new Date(Date.now() + _timeOffset)
}
// ====== AWS V4 签名 ======
/**
* 为 PUT 请求构建 Authorization 签名头
*/
function buildAuthHeader(objectKey, date, contentType) {
const dateStr = date.getFullYear() + pad(date.getMonth() + 1) + pad(date.getDate())
const amzDate = dateStr + 'T' + pad(date.getHours()) + pad(date.getMinutes()) + pad(date.getSeconds()) + 'Z'
const host = CFG.endpoint
const canonicalUri = '/' + CFG.bucket + '/' + objectKey
const signedHeadersMap = {
'content-type': contentType || 'image/jpeg',
host: host,
'x-amz-content-sha256': 'UNSIGNED-PAYLOAD',
'x-amz-date': amzDate
}
const signedHeadersList = Object.keys(signedHeadersMap).sort()
const canonicalHeaders = signedHeadersList.map(k => k + ':' + signedHeadersMap[k] + '\n').join('')
const signedHeadersStr = signedHeadersList.join(';')
const canonicalRequest = [
'PUT',
canonicalUri,
'',
canonicalHeaders,
signedHeadersStr,
'UNSIGNED-PAYLOAD'
].join('\n')
const credentialScope = [dateStr, CFG.region, 's3', 'aws4_request'].join('/')
const stringToSign = [
'AWS4-HMAC-SHA256',
amzDate,
credentialScope,
sha256Hex(canonicalRequest)
].join('\n')
const signingKey = getSignatureKey(CFG.secretKey, dateStr, CFG.region, 's3')
const signature = bytesToHex(hmacRaw(signingKey, stringToSign))
const authHeader = 'AWS4-HMAC-SHA256 Credential=' + CFG.accessKey + '/' + credentialScope +
', SignedHeaders=' + signedHeadersStr + ', Signature=' + signature
return { authHeader, amzDate, host }
}
// ====== 平台兼容工具 ======
/**
* 获取当前平台可用的 XHR 构造函数
* HBuilder App: plus.net.XMLHttpRequest
* H5 / 小程序: XMLHttpRequest
*/
function getXHR() {
if (typeof plus !== 'undefined' && plus.net && plus.net.XMLHttpRequest) {
return plus.net.XMLHttpRequest
}
if (typeof XMLHttpRequest !== 'undefined') {
return XMLHttpRequest
}
return null
}
/**
* 在当前平台发送 PUT 二进制请求到 MinIO
*/
function minioPut(url, arrayBuffer, contentType, authHeader, amzDate) {
return new Promise(function (resolve, reject) {
var XHRClass = getXHR()
if (XHRClass) {
var xhr = new XHRClass()
xhr.open('PUT', url, true)
xhr.setRequestHeader('Authorization', authHeader)
xhr.setRequestHeader('x-amz-content-sha256', 'UNSIGNED-PAYLOAD')
xhr.setRequestHeader('x-amz-date', amzDate)
xhr.setRequestHeader('Content-Type', contentType)
xhr.onload = function () {
if (xhr.status === 200) {
console.log('【MinIO上传】成功:', url)
resolve(url)
} else {
console.error('【MinIO上传】失败, HTTP:', xhr.status, xhr.responseText || xhr.response)
reject(new Error('上传失败: HTTP ' + xhr.status))
}
}
xhr.onerror = function () {
console.error('【MinIO上传】网络错误')
reject(new Error('上传网络错误'))
}
xhr.ontimeout = function () {
reject(new Error('上传超时'))
}
xhr.timeout = 120000
xhr.send(arrayBuffer)
} else {
// 无可用 XHR → 使用 uni.request + base64 body
uni.request({
url: url,
method: 'PUT',
header: {
'Authorization': authHeader,
'x-amz-content-sha256': 'UNSIGNED-PAYLOAD',
'x-amz-date': amzDate,
'Content-Type': contentType
},
data: arrayBuffer,
success: function (res) {
if (res.statusCode === 200) {
resolve(url)
} else {
reject(new Error('上传失败: HTTP ' + res.statusCode))
}
},
fail: function (err) {
reject(new Error('上传网络错误: ' + JSON.stringify(err)))
}
})
}
})
}
// ====== 文件读取(兼容 uniapp 多端) ======
/**
* 读取本地图片为 base64
* 优先级plus.io FileReader → uni.getFileSystemManager → XHR
*/
function readFileAsBase64(filePath) {
return new Promise((resolve, reject) => {
if (typeof plus !== 'undefined' && plus.io && plus.io.FileReader) {
// HBuilder App 原生:通过 resolveLocalFileSystemURL 获取文件入口
plus.io.resolveLocalFileSystemURL(filePath, function (entry) {
entry.file(function (file) {
const reader = new plus.io.FileReader()
reader.onloadend = function (e) {
var base64 = e.target.result
// 如果返回 data:image/... 格式,去掉前缀
if (base64.indexOf(',') > -1) {
base64 = base64.split(',')[1]
}
resolve(base64)
}
reader.onerror = function () {
reject(new Error('plus.io.FileReader 读取失败'))
}
reader.readAsDataURL(file)
}, function () {
reject(new Error('获取文件对象失败'))
})
}, function (err) {
console.warn('resolveLocalFileSystemURL 失败,尝试 getFileSystemManager:', JSON.stringify(err))
tryGetFileSystemManager(filePath, resolve, reject)
})
} else {
tryGetFileSystemManager(filePath, resolve, reject)
}
})
}
/**
* 尝试通过 uni.getFileSystemManager 读取文件uni-app 支持)
*/
function tryGetFileSystemManager(filePath, resolve, reject) {
try {
const fs = uni.getFileSystemManager()
if (fs && fs.readFile) {
fs.readFile({
filePath: filePath,
encoding: 'base64',
success: function (res) {
resolve(res.data)
},
fail: function () {
console.warn('getFileSystemManager 失败,回退到 XHR')
readViaXHR(filePath, resolve, reject)
}
})
} else {
readViaXHR(filePath, resolve, reject)
}
} catch (e) {
readViaXHR(filePath, resolve, reject)
}
}
/**
* 通过 XMLHttpRequest 读取本地文件H5 / 开发环境)
*/
function readViaXHR(filePath, resolve, reject) {
try {
var XHRClass = getXHR()
if (!XHRClass) {
reject(new Error('当前环境不支持XHR'))
return
}
var xhr = new XHRClass()
xhr.open('GET', filePath, true)
if (typeof Blob !== 'undefined' && typeof FileReader !== 'undefined') {
xhr.responseType = 'blob'
xhr.onload = function () {
var reader = new FileReader()
reader.onload = function () {
var base64 = reader.result.split(',')[1] || ''
resolve(base64)
}
reader.onerror = function () {
reject(new Error('FileReader 读取失败'))
}
reader.readAsDataURL(xhr.response)
}
} else {
// 无 FileReader → 读为 textApp com.6 起支持获取本地文件 base64
xhr.responseType = 'text'
xhr.onload = function () {
resolve(xhr.responseText || '')
}
}
xhr.onerror = function () {
reject(new Error('XHR 读取文件失败'))
}
xhr.send()
} catch (e) {
reject(new Error('读取文件异常: ' + e.message))
}
}
/**
* Base64 转 ArrayBuffer
*/
function base64ToArrayBuffer(base64) {
const binaryStr = atob(base64)
const bytes = new Uint8Array(binaryStr.length)
for (let i = 0; i < binaryStr.length; i++) {
bytes[i] = binaryStr.charCodeAt(i)
}
return bytes.buffer
}
/**
* 生成 UUID
*/
function generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = Math.random() * 16 | 0
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16)
})
}
/**
* 获取文件扩展名(不含点)
*/
function getFileExt(filePath) {
const m = filePath.match(/\.(\w+)$/)
return m ? m[1].toLowerCase() : 'jpg'
}
/**
* 根据扩展名获取 Content-Type
*/
function getContentType(ext) {
const map = {
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
png: 'image/png',
gif: 'image/gif',
webp: 'image/webp',
bmp: 'image/bmp'
}
return map[ext] || 'image/jpeg'
}
// ====== 主上传接口 ======
/**
* 上传单张图片到 MinIO
* @param {string} filePath - 本地文件路径uni.chooseImage 返回的 tempFilePath
* @param {string} typeDir - 目录名,默认 'Isbn'
* @returns {Promise<string>} 可公开访问的图片 URL
*/
export function uploadImage(filePath, typeDir = 'Isbn') {
return new Promise(function (resolve, reject) {
syncServerTime().then(function (serverDate) {
readFileAsBase64(filePath)
.then(function (base64) {
const arrayBuffer = base64ToArrayBuffer(base64)
// 构建对象路径(使用服务器时间)
const now = serverDate
const datePath = now.getFullYear() + '-' + pad(now.getMonth() + 1) + '-' + pad(now.getDate())
const ext = getFileExt(filePath)
const objectKey = datePath + '/' + typeDir + '/' + generateUUID() + '.' + ext
const contentType = getContentType(ext)
// 构建 AWS V4 签名
const { authHeader, amzDate, host } = buildAuthHeader(objectKey, now, contentType)
// PUT URL
const url = CFG.protocol + '://' + host + '/' + CFG.bucket + '/' + objectKey
console.log('【MinIO上传】URL:', url)
console.log('【MinIO上传】contentType:', contentType)
console.log('【MinIO上传】contentLength:', arrayBuffer.byteLength)
// PUT 到 MinIO
minioPut(url, arrayBuffer, contentType, authHeader, amzDate).then(function (resultUrl) {
resolve(resultUrl)
}).catch(function (err) {
reject(err)
})
})
.catch(function (err) {
reject(err)
})
})
})
}
/**
* 批量上传多张图片到 MinIO
* @param {string[]} filePaths - 本地文件路径数组
* @param {string} typeDir - 目录名
* @returns {Promise<string[]>} 图片 URL 数组
*/
export function uploadImages(filePaths, typeDir = 'Isbn') {
const tasks = filePaths.map(function (fp) {
return uploadImage(fp, typeDir)
})
return Promise.all(tasks)
}
export default { uploadImage, uploadImages }