feat: 分片上传断点续传、临时目录后台配置与清扫、宇恒云账号管理

- 管理端大文件分片上传与 sessionStorage 续传;Nginx 大请求体/超时
- .chunk-uploads 定期清扫;system_config 后台配置保留时长与扫描间隔
- 宇恒云 POST /register 对接与 yuheng_cloud_register_records 留痕;yuheng_cloud:manage 权限

Made-with: Cursor
This commit is contained in:
whm
2026-04-13 14:50:27 +08:00
parent 03f5fbb41a
commit 0800982224
20 changed files with 1413 additions and 47 deletions

View 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 (_) {}
}