- 前端 FormData+chunk,避免 raw body 被中间层断连 - Gin 分片路由置于 POST .../assets 之前 - 分片并发降为 1 Made-with: Cursor
154 lines
4.6 KiB
JavaScript
154 lines
4.6 KiB
JavaScript
import {
|
||
abortMultipartUpload,
|
||
completeMultipartUpload,
|
||
getMultipartUploadStatus,
|
||
initMultipartUpload,
|
||
putMultipartChunk,
|
||
uploadSiteAsset
|
||
} from '../api/admin'
|
||
|
||
const CHUNK_THRESHOLD = 8 * 1024 * 1024
|
||
const DEFAULT_CHUNK_SIZE = 4 * 1024 * 1024
|
||
// 串行上传分片,避免 HTTP/2 多路复用 + 大 body 在部分反代上不稳定
|
||
const UPLOAD_CONCURRENCY = 1
|
||
|
||
function fileFingerprint(file) {
|
||
return `${file.name}\t${file.size}\t${file.lastModified}`
|
||
}
|
||
|
||
function storageKey(siteId) {
|
||
return `yh_resumable_asset_${siteId}`
|
||
}
|
||
|
||
/**
|
||
* 站点资源上传:小于阈值整包 POST;大于等于阈值走分片 + sessionStorage 断点续传(同文件同站点刷新后可续传)。
|
||
* @param {string} siteId
|
||
* @param {File|Blob} file
|
||
* @param {{ folder?: string, downloadable?: boolean, preserveFilename?: boolean }} opts
|
||
* @param {{ onProgress?: (p: { percent: number, loaded: number, total: number }) => void }} callbacks
|
||
*/
|
||
export async function uploadSiteAssetWithResume(siteId, file, opts = {}, callbacks = {}) {
|
||
const onProgress = typeof callbacks.onProgress === 'function' ? callbacks.onProgress : null
|
||
const total = file.size
|
||
if (total <= CHUNK_THRESHOLD) {
|
||
if (onProgress) onProgress({ percent: 0, loaded: 0, total })
|
||
const res = await uploadSiteAsset(siteId, file, opts)
|
||
if (onProgress) onProgress({ percent: 100, loaded: total, total })
|
||
return res
|
||
}
|
||
|
||
const fp = fileFingerprint(file)
|
||
const key = storageKey(siteId)
|
||
let uploadId = null
|
||
let chunkSize = DEFAULT_CHUNK_SIZE
|
||
let totalChunks = 0
|
||
const received = new Set()
|
||
|
||
let cached = null
|
||
try {
|
||
cached = JSON.parse(sessionStorage.getItem(key) || 'null')
|
||
} catch (_) {
|
||
cached = null
|
||
}
|
||
|
||
if (cached && cached.fingerprint === fp && cached.upload_id) {
|
||
try {
|
||
const st = await getMultipartUploadStatus(siteId, cached.upload_id)
|
||
if (
|
||
st.total_size === file.size &&
|
||
st.original_filename === file.name &&
|
||
typeof st.chunk_size === 'number' &&
|
||
st.chunk_size > 0
|
||
) {
|
||
uploadId = cached.upload_id
|
||
chunkSize = st.chunk_size
|
||
totalChunks = st.total_chunks
|
||
for (const i of st.received_chunks || []) {
|
||
received.add(i)
|
||
}
|
||
}
|
||
} catch (_) {
|
||
uploadId = null
|
||
received.clear()
|
||
}
|
||
}
|
||
|
||
if (!uploadId) {
|
||
const init = await initMultipartUpload(siteId, {
|
||
filename: file.name,
|
||
total_size: file.size,
|
||
chunk_size: DEFAULT_CHUNK_SIZE,
|
||
folder: opts.folder || '',
|
||
downloadable: Boolean(opts.downloadable),
|
||
preserve_filename: Boolean(opts.preserveFilename)
|
||
})
|
||
uploadId = init.upload_id
|
||
chunkSize = init.chunk_size || DEFAULT_CHUNK_SIZE
|
||
totalChunks = init.total_chunks
|
||
sessionStorage.setItem(key, JSON.stringify({ fingerprint: fp, upload_id: uploadId, total_chunks: totalChunks }))
|
||
}
|
||
|
||
const missing = []
|
||
for (let i = 0; i < totalChunks; i++) {
|
||
if (!received.has(i)) missing.push(i)
|
||
}
|
||
|
||
const chunkByteLength = (idx) => {
|
||
const start = idx * chunkSize
|
||
const end = Math.min(start + chunkSize, file.size)
|
||
return end - start
|
||
}
|
||
|
||
let uploadedBytes = 0
|
||
for (let i = 0; i < totalChunks; i++) {
|
||
if (received.has(i)) uploadedBytes += chunkByteLength(i)
|
||
}
|
||
|
||
const reportProgress = () => {
|
||
if (!onProgress) return
|
||
const pct = total <= 0 ? 100 : Math.min(99, Math.round((uploadedBytes / total) * 100))
|
||
onProgress({ percent: pct, loaded: uploadedBytes, total })
|
||
}
|
||
reportProgress()
|
||
|
||
const queue = [...missing]
|
||
const worker = async () => {
|
||
while (queue.length) {
|
||
const idx = queue.shift()
|
||
if (idx === undefined) break
|
||
const start = idx * chunkSize
|
||
const end = Math.min(start + chunkSize, file.size)
|
||
const blob = file.slice(start, end)
|
||
await putMultipartChunk(siteId, uploadId, idx, blob)
|
||
uploadedBytes += end - start
|
||
reportProgress()
|
||
}
|
||
}
|
||
await Promise.all(Array.from({ length: Math.min(UPLOAD_CONCURRENCY, Math.max(1, missing.length)) }, () => worker()))
|
||
|
||
const done = await completeMultipartUpload(siteId, uploadId)
|
||
try {
|
||
sessionStorage.removeItem(key)
|
||
} catch (_) {}
|
||
if (onProgress) onProgress({ percent: 100, loaded: total, total })
|
||
return done
|
||
}
|
||
|
||
/** 放弃当前站点的分片会话(可选) */
|
||
export async function abortSiteAssetResumable(siteId) {
|
||
const key = storageKey(siteId)
|
||
let cached = null
|
||
try {
|
||
cached = JSON.parse(sessionStorage.getItem(key) || 'null')
|
||
} catch (_) {
|
||
cached = null
|
||
}
|
||
if (!cached?.upload_id) return
|
||
try {
|
||
await abortMultipartUpload(siteId, cached.upload_id)
|
||
} catch (_) {}
|
||
try {
|
||
sessionStorage.removeItem(key)
|
||
} catch (_) {}
|
||
}
|