|
|
@@ -13,6 +13,7 @@ export interface UploadConfig {
|
|
|
export class Uploader {
|
|
|
private static readonly DEFAULT_UPLOAD_HOST = 'https://up.meitudata.com'
|
|
|
private static readonly BACKUP_UPLOAD_HOST = 'https://upload.meitudata.com'
|
|
|
+ private static readonly CHUNK_SIZE = 4 * 1024 * 1024; // 4MB 分片上传
|
|
|
|
|
|
/**
|
|
|
* 检查网络连接和DNS解析
|
|
|
@@ -38,43 +39,165 @@ export class Uploader {
|
|
|
}
|
|
|
|
|
|
static async uploadFile(filePath: string, config: UploadConfig): Promise<string> {
|
|
|
- const stat = FileUtil.lstatSync(filePath)
|
|
|
- const file = FileUtil.openSync(filePath, fs.OpenMode.READ_ONLY)
|
|
|
try {
|
|
|
- const buffer = new ArrayBuffer(stat.size)
|
|
|
- FileUtil.readSync(file.fd, buffer, { offset: 0, length: stat.size })
|
|
|
- const u8 = new Uint8Array(buffer)
|
|
|
- const contentBase64 = Base64Util.encodeToStrSync(u8)
|
|
|
+ const stat = FileUtil.lstatSync(filePath)
|
|
|
const key = Uploader.buildKey(filePath, config)
|
|
|
const keyBase64 = Uploader.toUrlSafeBase64(key)
|
|
|
const mainHost = config.uploadHost ?? Uploader.DEFAULT_UPLOAD_HOST
|
|
|
|
|
|
LogUtil.info('Uploader', `开始上传文件: ${filePath}, 大小: ${stat.size} bytes`)
|
|
|
- LogUtil.info('Uploader', `使用上传主机: ${mainHost}`)
|
|
|
|
|
|
- // 对于 401 错误,先尝试直接上传不做网络预检查
|
|
|
try {
|
|
|
LogUtil.info('Uploader', `直接尝试主主机上传: ${mainHost}`)
|
|
|
- return await Uploader.doUpload(mainHost, stat.size, key, keyBase64, contentBase64, config)
|
|
|
+ return await Uploader.performUploadWithHost(mainHost, filePath, stat.size, key, keyBase64, config)
|
|
|
} catch (err) {
|
|
|
LogUtil.warn('Uploader', `主上传Host失败(${mainHost}): ${(err as Error).message},尝试预检查后使用备用`)
|
|
|
-
|
|
|
- // 预检查主机的网络连接性
|
|
|
const backupHostReachable = await Uploader.checkNetworkConnectivity(Uploader.BACKUP_UPLOAD_HOST)
|
|
|
if (!backupHostReachable) {
|
|
|
throw new Error(`上传失败且备用主机不可达: ${(err as Error).message}`)
|
|
|
}
|
|
|
LogUtil.info('Uploader', `使用备用主机上传: ${Uploader.BACKUP_UPLOAD_HOST}`)
|
|
|
- return await Uploader.doUpload(Uploader.BACKUP_UPLOAD_HOST, stat.size, key, keyBase64, contentBase64, config)
|
|
|
+ return await Uploader.performUploadWithHost(Uploader.BACKUP_UPLOAD_HOST, filePath, stat.size, key, keyBase64, config)
|
|
|
}
|
|
|
} catch (err) {
|
|
|
LogUtil.error('Uploader', `上传异常: ${(err as Error).message}`)
|
|
|
throw new Error((err as Error).message)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static async performUploadWithHost(
|
|
|
+ host: string,
|
|
|
+ filePath: string,
|
|
|
+ fileSize: number,
|
|
|
+ key: string,
|
|
|
+ keyBase64: string,
|
|
|
+ config: UploadConfig
|
|
|
+ ): Promise<string> {
|
|
|
+ LogUtil.info('Uploader', `使用上传主机: ${host}`)
|
|
|
+ if (fileSize > Uploader.CHUNK_SIZE) {
|
|
|
+ LogUtil.info('Uploader', `文件较大,启用分片上传: ${fileSize} bytes (chunk=${Uploader.CHUNK_SIZE})`)
|
|
|
+ return await Uploader.uploadFileChunked(host, filePath, fileSize, key, keyBase64, config)
|
|
|
+ }
|
|
|
+ const contentBase64 = await Uploader.readFileAsBase64(filePath, fileSize)
|
|
|
+ return await Uploader.doUploadBase64(host, fileSize, key, keyBase64, contentBase64, config)
|
|
|
+ }
|
|
|
+
|
|
|
+ private static async readFileAsBase64(filePath: string, size: number): Promise<string> {
|
|
|
+ const file = FileUtil.openSync(filePath, fs.OpenMode.READ_ONLY)
|
|
|
+ try {
|
|
|
+ const buffer = new ArrayBuffer(size)
|
|
|
+ FileUtil.readSync(file.fd, buffer, { offset: 0, length: size })
|
|
|
+ const u8 = new Uint8Array(buffer)
|
|
|
+ return Base64Util.encodeToStrSync(u8)
|
|
|
} finally {
|
|
|
FileUtil.closeSync(file.fd)
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ private static async uploadFileChunked(
|
|
|
+ host: string,
|
|
|
+ filePath: string,
|
|
|
+ fileSize: number,
|
|
|
+ key: string,
|
|
|
+ keyBase64: string,
|
|
|
+ config: UploadConfig
|
|
|
+ ): Promise<string> {
|
|
|
+ const contexts: string[] = []
|
|
|
+ const file = FileUtil.openSync(filePath, fs.OpenMode.READ_ONLY)
|
|
|
+ const totalChunks = Math.ceil(fileSize / Uploader.CHUNK_SIZE)
|
|
|
+ let offset = 0
|
|
|
+ let chunkIndex = 0
|
|
|
+ try {
|
|
|
+ while (offset < fileSize) {
|
|
|
+ const remaining = fileSize - offset
|
|
|
+ const chunkSize = Math.min(remaining, Uploader.CHUNK_SIZE)
|
|
|
+ const buffer = new ArrayBuffer(chunkSize)
|
|
|
+ FileUtil.readSync(file.fd, buffer, { offset, length: chunkSize })
|
|
|
+ chunkIndex++
|
|
|
+ LogUtil.info('Uploader', `上传分片 ${chunkIndex}/${totalChunks}, 偏移: ${offset}, 大小: ${chunkSize}`)
|
|
|
+ const ctx = await Uploader.uploadChunk(host, buffer, chunkSize, config)
|
|
|
+ contexts.push(ctx)
|
|
|
+ offset += chunkSize
|
|
|
+ }
|
|
|
+ } finally {
|
|
|
+ FileUtil.closeSync(file.fd)
|
|
|
+ }
|
|
|
+ return await Uploader.finalizeChunks(host, fileSize, key, keyBase64, contexts, config)
|
|
|
+ }
|
|
|
+
|
|
|
+ private static async uploadChunk(
|
|
|
+ host: string,
|
|
|
+ chunkData: ArrayBuffer,
|
|
|
+ chunkSize: number,
|
|
|
+ config: UploadConfig
|
|
|
+ ): Promise<string> {
|
|
|
+ const url = `${host}/mkblk/${chunkSize}`
|
|
|
+ const httpRequest = http.createHttp()
|
|
|
+ try {
|
|
|
+ const options: http.HttpRequestOptions = {
|
|
|
+ method: http.RequestMethod.POST,
|
|
|
+ connectTimeout: 10000,
|
|
|
+ readTimeout: 20000,
|
|
|
+ expectDataType: http.HttpDataType.STRING,
|
|
|
+ header: {
|
|
|
+ 'Content-Type': 'application/octet-stream',
|
|
|
+ 'Authorization': `UpToken ${config.uploadToken}`,
|
|
|
+ 'User-Agent': 'MZXJ/1.0'
|
|
|
+ },
|
|
|
+ extraData: chunkData
|
|
|
+ }
|
|
|
+ const response = await httpRequest.request(url, options)
|
|
|
+ if (response.responseCode !== 200) {
|
|
|
+ throw new Error(`分片上传失败: HTTP ${response.responseCode}`)
|
|
|
+ }
|
|
|
+ const body = JSON.parse(response.result as string) as Record<string, string>
|
|
|
+ const ctx = body['ctx']
|
|
|
+ if (!ctx) {
|
|
|
+ throw new Error('分片上传返回无ctx')
|
|
|
+ }
|
|
|
+ return ctx
|
|
|
+ } finally {
|
|
|
+ httpRequest.destroy()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ private static async finalizeChunks(
|
|
|
+ host: string,
|
|
|
+ fileSize: number,
|
|
|
+ key: string,
|
|
|
+ keyBase64: string,
|
|
|
+ contexts: string[],
|
|
|
+ config: UploadConfig
|
|
|
+ ): Promise<string> {
|
|
|
+ const url = `${host}/mkfile/${fileSize}/key/${encodeURIComponent(keyBase64)}`
|
|
|
+ const body = contexts.join(',')
|
|
|
+ const httpRequest = http.createHttp()
|
|
|
+ try {
|
|
|
+ const options: http.HttpRequestOptions = {
|
|
|
+ method: http.RequestMethod.POST,
|
|
|
+ connectTimeout: 10000,
|
|
|
+ readTimeout: 20000,
|
|
|
+ expectDataType: http.HttpDataType.STRING,
|
|
|
+ header: {
|
|
|
+ 'Content-Type': 'text/plain',
|
|
|
+ 'Authorization': `UpToken ${config.uploadToken}`,
|
|
|
+ 'User-Agent': 'MZXJ/1.0'
|
|
|
+ },
|
|
|
+ extraData: body
|
|
|
+ }
|
|
|
+ const response = await httpRequest.request(url, options)
|
|
|
+ if (response.responseCode !== 200) {
|
|
|
+ throw new Error(`mkfile 失败: HTTP ${response.responseCode}`)
|
|
|
+ }
|
|
|
+ const result = JSON.parse(response.result as string) as Record<string, string>
|
|
|
+ const savedKey = result['key'] ?? key
|
|
|
+ const domain = Uploader.normalizeDomain(config.domain)
|
|
|
+ return `${domain}/${savedKey}`
|
|
|
+ } finally {
|
|
|
+ httpRequest.destroy()
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
private static buildKey(filePath: string, config: UploadConfig): string {
|
|
|
const prefix = config.keyPrefix ?? 'ttmusic-logs'
|
|
|
const filename = FileUtil.getFileName(filePath)
|
|
|
@@ -96,7 +219,7 @@ export class Uploader {
|
|
|
return domain
|
|
|
}
|
|
|
|
|
|
- private static async doUpload(host: string, size: number, key: string, keyBase64: string, contentBase64: string, config: UploadConfig): Promise<string> {
|
|
|
+ private static async doUploadBase64(host: string, size: number, key: string, keyBase64: string, contentBase64: string, config: UploadConfig): Promise<string> {
|
|
|
const url = `${host}/putb64/${size}/key/${encodeURIComponent(keyBase64)}`
|
|
|
|
|
|
// 调试信息
|