feat: 分片上传断点续传、临时目录后台配置与清扫、宇恒云账号管理
- 管理端大文件分片上传与 sessionStorage 续传;Nginx 大请求体/超时 - .chunk-uploads 定期清扫;system_config 后台配置保留时长与扫描间隔 - 宇恒云 POST /register 对接与 yuheng_cloud_register_records 留痕;yuheng_cloud:manage 权限 Made-with: Cursor
This commit is contained in:
152
admin/src/utils/siteAssetResumableUpload.js
Normal file
152
admin/src/utils/siteAssetResumableUpload.js
Normal file
@@ -0,0 +1,152 @@
|
||||
import {
|
||||
abortMultipartUpload,
|
||||
completeMultipartUpload,
|
||||
getMultipartUploadStatus,
|
||||
initMultipartUpload,
|
||||
putMultipartChunk,
|
||||
uploadSiteAsset
|
||||
} from '../api/admin'
|
||||
|
||||
const CHUNK_THRESHOLD = 8 * 1024 * 1024
|
||||
const DEFAULT_CHUNK_SIZE = 4 * 1024 * 1024
|
||||
const UPLOAD_CONCURRENCY = 3
|
||||
|
||||
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 (_) {}
|
||||
}
|
||||
Reference in New Issue
Block a user